koishi-plugin-chat-analyse 0.5.4 → 0.6.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/Analyse.d.ts +22 -0
- package/lib/Renderer.d.ts +9 -0
- package/lib/Stat.d.ts +0 -14
- package/lib/index.d.ts +35 -1
- package/lib/index.js +244 -74
- package/package.json +7 -3
package/lib/Analyse.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Context, Command } from 'koishi';
|
|
2
|
+
import { Config } from './index';
|
|
3
|
+
export interface WordCloudData {
|
|
4
|
+
title: string;
|
|
5
|
+
time: Date;
|
|
6
|
+
words: [string, number][];
|
|
7
|
+
}
|
|
8
|
+
export declare class Analyse {
|
|
9
|
+
private ctx;
|
|
10
|
+
private config;
|
|
11
|
+
private renderer;
|
|
12
|
+
private nlp;
|
|
13
|
+
private isNlpReady;
|
|
14
|
+
constructor(ctx: Context, config: Config);
|
|
15
|
+
/**
|
|
16
|
+
* @private
|
|
17
|
+
* @method initializeNlp
|
|
18
|
+
* @description 异步加载并训练 NLP 模型。
|
|
19
|
+
*/
|
|
20
|
+
private initializeNlp;
|
|
21
|
+
registerCommands(cmd: Command): void;
|
|
22
|
+
}
|
package/lib/Renderer.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Context } from 'koishi';
|
|
2
|
+
import { WordCloudData } from './Analyse';
|
|
2
3
|
/**
|
|
3
4
|
* @interface ListRenderData
|
|
4
5
|
* @description 定义了调用 `renderList` 方法所需的数据结构。
|
|
@@ -74,4 +75,12 @@ export declare class Renderer {
|
|
|
74
75
|
* @returns {Promise<string | Buffer[]>} - 成功时返回包含图片 Buffer 的数组,失败或无数据时返回提示字符串。
|
|
75
76
|
*/
|
|
76
77
|
renderCircadianChart(data: CircadianChartData): Promise<string | Buffer[]>;
|
|
78
|
+
/**
|
|
79
|
+
* @public
|
|
80
|
+
* @method renderWordCloud
|
|
81
|
+
* @description 将词频数据渲染成一张词云图片,使用 Puppeteer 和 wordcloud2.js。
|
|
82
|
+
* @param {WordCloudData} data - 包含标题、时间和词汇列表的对象。
|
|
83
|
+
* @returns {Promise<string | Buffer[]>} - 成功时返回图片 Buffer 数组,否则返回提示。
|
|
84
|
+
*/
|
|
85
|
+
renderWordCloud(data: WordCloudData): Promise<string | Buffer[]>;
|
|
77
86
|
}
|
package/lib/Stat.d.ts
CHANGED
|
@@ -20,18 +20,4 @@ export declare class Stat {
|
|
|
20
20
|
* @param cmd - 主命令实例。
|
|
21
21
|
*/
|
|
22
22
|
registerCommands(cmd: Command): void;
|
|
23
|
-
/**
|
|
24
|
-
* @private @method parseQueryScope
|
|
25
|
-
* @description 解析命令选项,转换为包含 UIDs 和描述性信息的统一查询范围对象。
|
|
26
|
-
* @param session - 当前会话对象。
|
|
27
|
-
* @param options - 命令选项。
|
|
28
|
-
* @returns 包含 uids、错误或范围描述的查询范围对象。
|
|
29
|
-
*/
|
|
30
|
-
private parseQueryScope;
|
|
31
|
-
/**
|
|
32
|
-
* @private @method generateTitle
|
|
33
|
-
* @description 根据查询范围和类型动态生成易于理解的图片标题。
|
|
34
|
-
* @returns 生成的标题字符串。
|
|
35
|
-
*/
|
|
36
|
-
private generateTitle;
|
|
37
23
|
}
|
package/lib/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Context, Schema } from 'koishi';
|
|
1
|
+
import { Context, Schema, Session } from 'koishi';
|
|
2
2
|
/** @name 插件使用说明 */
|
|
3
3
|
export declare const usage = "\n<div style=\"border-radius: 10px; border: 1px solid #ddd; padding: 16px; margin-bottom: 20px; box-shadow: 0 2px 5px rgba(0,0,0,0.1);\">\n <h2 style=\"margin-top: 0; color: #4a6ee0;\">\uD83D\uDCCC \u63D2\u4EF6\u8BF4\u660E</h2>\n <p>\uD83D\uDCD6 <strong>\u4F7F\u7528\u6587\u6863</strong>\uFF1A\u8BF7\u70B9\u51FB\u5DE6\u4E0A\u89D2\u7684 <strong>\u63D2\u4EF6\u4E3B\u9875</strong> \u67E5\u770B\u63D2\u4EF6\u4F7F\u7528\u6587\u6863</p>\n <p>\uD83D\uDD0D <strong>\u66F4\u591A\u63D2\u4EF6</strong>\uFF1A\u53EF\u8BBF\u95EE <a href=\"https://github.com/YisRime\" style=\"color:#4a6ee0;text-decoration:none;\">\u82E1\u6DDE\u7684 GitHub</a> \u67E5\u770B\u672C\u4EBA\u7684\u6240\u6709\u63D2\u4EF6</p>\n</div>\n<div style=\"border-radius: 10px; border: 1px solid #ddd; padding: 16px; margin-bottom: 20px; box-shadow: 0 2px 5px rgba(0,0,0,0.1);\">\n <h2 style=\"margin-top: 0; color: #e0574a;\">\u2764\uFE0F \u652F\u6301\u4E0E\u53CD\u9988</h2>\n <p>\uD83C\uDF1F \u559C\u6B22\u8FD9\u4E2A\u63D2\u4EF6\uFF1F\u8BF7\u5728 <a href=\"https://github.com/YisRime\" style=\"color:#e0574a;text-decoration:none;\">GitHub</a> \u4E0A\u7ED9\u6211\u4E00\u4E2A Star\uFF01</p>\n <p>\uD83D\uDC1B \u9047\u5230\u95EE\u9898\uFF1F\u8BF7\u901A\u8FC7 <strong>Issues</strong> \u63D0\u4EA4\u53CD\u9988\uFF0C\u6216\u52A0\u5165 QQ \u7FA4 <a href=\"https://qm.qq.com/q/PdLMx9Jowq\" style=\"color:#e0574a;text-decoration:none;\"><strong>855571375</strong></a> \u8FDB\u884C\u4EA4\u6D41</p>\n</div>\n";
|
|
4
4
|
export declare const name = "chat-analyse";
|
|
@@ -18,9 +18,43 @@ export interface Config {
|
|
|
18
18
|
enableDataIO: boolean;
|
|
19
19
|
atRetentionDays: number;
|
|
20
20
|
rankRetentionDays: number;
|
|
21
|
+
enableWordCloud: boolean;
|
|
22
|
+
enableVocabulary: boolean;
|
|
21
23
|
}
|
|
22
24
|
/** @description 插件的配置项定义 */
|
|
23
25
|
export declare const Config: Schema<Config>;
|
|
26
|
+
/**
|
|
27
|
+
* @private @method parseQueryScope
|
|
28
|
+
* @description 解析命令选项,转换为包含 UIDs 和描述性信息的统一查询范围对象。
|
|
29
|
+
* @param session - 当前会话对象。
|
|
30
|
+
* @param options - 命令选项。
|
|
31
|
+
* @returns 包含 uids、错误或范围描述的查询范围对象。
|
|
32
|
+
*/
|
|
33
|
+
export declare function parseQueryScope(ctx: Context, session: Session, options: {
|
|
34
|
+
user?: string;
|
|
35
|
+
guild?: string;
|
|
36
|
+
all?: boolean;
|
|
37
|
+
}): Promise<{
|
|
38
|
+
uids?: number[];
|
|
39
|
+
error?: string;
|
|
40
|
+
scopeDesc: {
|
|
41
|
+
guildId?: string;
|
|
42
|
+
userId?: string;
|
|
43
|
+
};
|
|
44
|
+
}>;
|
|
45
|
+
/**
|
|
46
|
+
* @private @method generateTitle
|
|
47
|
+
* @description 根据查询范围和类型动态生成易于理解的图片标题。
|
|
48
|
+
* @returns 生成的标题字符串。
|
|
49
|
+
*/
|
|
50
|
+
export declare function generateTitle(ctx: Context, scopeDesc: {
|
|
51
|
+
guildId?: string;
|
|
52
|
+
userId?: string;
|
|
53
|
+
}, options: {
|
|
54
|
+
main: string;
|
|
55
|
+
subtype?: string;
|
|
56
|
+
timeRange?: number;
|
|
57
|
+
}): Promise<string>;
|
|
24
58
|
/**
|
|
25
59
|
* @function apply
|
|
26
60
|
* @description Koishi 插件的主入口函数,负责初始化和注册所有功能模块。
|
package/lib/index.js
CHANGED
|
@@ -30,14 +30,16 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
30
30
|
// src/index.ts
|
|
31
31
|
var src_exports = {};
|
|
32
32
|
__export(src_exports, {
|
|
33
|
-
Config: () =>
|
|
33
|
+
Config: () => Config3,
|
|
34
34
|
apply: () => apply,
|
|
35
|
+
generateTitle: () => generateTitle,
|
|
35
36
|
name: () => name,
|
|
37
|
+
parseQueryScope: () => parseQueryScope,
|
|
36
38
|
usage: () => usage,
|
|
37
39
|
using: () => using
|
|
38
40
|
});
|
|
39
41
|
module.exports = __toCommonJS(src_exports);
|
|
40
|
-
var
|
|
42
|
+
var import_koishi7 = require("koishi");
|
|
41
43
|
|
|
42
44
|
// src/Collector.ts
|
|
43
45
|
var import_koishi = require("koishi");
|
|
@@ -301,7 +303,7 @@ var Renderer = class {
|
|
|
301
303
|
async htmlToImage(fullHtmlContent) {
|
|
302
304
|
const page = await this.ctx.puppeteer.page();
|
|
303
305
|
try {
|
|
304
|
-
await page.setViewport({ width:
|
|
306
|
+
await page.setViewport({ width: 850, height: 10, deviceScaleFactor: 2 });
|
|
305
307
|
await page.setContent(fullHtmlContent, { waitUntil: "networkidle0" });
|
|
306
308
|
const { width, height } = await page.evaluate(() => ({
|
|
307
309
|
width: document.body.scrollWidth,
|
|
@@ -348,7 +350,7 @@ var Renderer = class {
|
|
|
348
350
|
const CHUNK_SIZE = 100;
|
|
349
351
|
const imageBuffers = [];
|
|
350
352
|
const totalItems = list.length;
|
|
351
|
-
const countHeaderIndex = headers?.findIndex((
|
|
353
|
+
const countHeaderIndex = headers?.findIndex((h6) => ["总计发言", "条数", "次数", "数量"].includes(h6)) ?? -1;
|
|
352
354
|
const totalCount = data.total || (countHeaderIndex > -1 ? list.reduce((sum, row) => sum + (Number(row[countHeaderIndex]) || 0), 0) : totalItems);
|
|
353
355
|
const renderCell = /* @__PURE__ */ __name((cell, i) => {
|
|
354
356
|
const headerText = headers?.[i] || "";
|
|
@@ -396,7 +398,7 @@ var Renderer = class {
|
|
|
396
398
|
<thead>
|
|
397
399
|
<tr>
|
|
398
400
|
<th class="rank-cell">#</th>
|
|
399
|
-
${headers.map((
|
|
401
|
+
${headers.map((h6) => `<th>${h6}</th>`).join("")}
|
|
400
402
|
</tr>
|
|
401
403
|
</thead>` : ""}
|
|
402
404
|
<tbody>
|
|
@@ -457,6 +459,54 @@ var Renderer = class {
|
|
|
457
459
|
const imageBuffer = await this.htmlToImage(fullHtml);
|
|
458
460
|
return imageBuffer ? [imageBuffer] : "图片渲染失败";
|
|
459
461
|
}
|
|
462
|
+
/**
|
|
463
|
+
* @public
|
|
464
|
+
* @method renderWordCloud
|
|
465
|
+
* @description 将词频数据渲染成一张词云图片,使用 Puppeteer 和 wordcloud2.js。
|
|
466
|
+
* @param {WordCloudData} data - 包含标题、时间和词汇列表的对象。
|
|
467
|
+
* @returns {Promise<string | Buffer[]>} - 成功时返回图片 Buffer 数组,否则返回提示。
|
|
468
|
+
*/
|
|
469
|
+
async renderWordCloud(data) {
|
|
470
|
+
const { title, time, words } = data;
|
|
471
|
+
if (!words?.length) return "暂无数据可供渲染";
|
|
472
|
+
const wordListJson = JSON.stringify(words);
|
|
473
|
+
const cardHtml = `
|
|
474
|
+
<div class="container">
|
|
475
|
+
<div class="header">
|
|
476
|
+
<div class="stat-chip">词数: <span>${words.length}</span></div>
|
|
477
|
+
<h1 class="title-text">${title}</h1>
|
|
478
|
+
<div class="time-label">${time.toLocaleString("zh-CN", { hour12: false })}</div>
|
|
479
|
+
</div>
|
|
480
|
+
<div id="wordcloud-container" style="width: 800px; height: 600px; margin: auto;"></div>
|
|
481
|
+
<script src="https://cdn.jsdelivr.net/npm/wordcloud@1.2.2/src/wordcloud2.js"></script>
|
|
482
|
+
<script>
|
|
483
|
+
WordCloud(document.getElementById('wordcloud-container'), {
|
|
484
|
+
list: ${wordListJson},
|
|
485
|
+
gridSize: 16,
|
|
486
|
+
weightFactor: (size) => Math.pow(size, 1.2) * 2.5,
|
|
487
|
+
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif",
|
|
488
|
+
color: 'random-dark',
|
|
489
|
+
backgroundColor: 'transparent',
|
|
490
|
+
rotateRatio: 0.5,
|
|
491
|
+
minRotation: -Math.PI / 6,
|
|
492
|
+
maxRotation: Math.PI / 6,
|
|
493
|
+
shuffle: false,
|
|
494
|
+
});
|
|
495
|
+
</script>
|
|
496
|
+
</div>`;
|
|
497
|
+
const fullHtml = `<!DOCTYPE html>
|
|
498
|
+
<html>
|
|
499
|
+
<head>
|
|
500
|
+
<meta charset="UTF-8">
|
|
501
|
+
<style>${this.COMMON_STYLE}</style>
|
|
502
|
+
</head>
|
|
503
|
+
<body>
|
|
504
|
+
${cardHtml}
|
|
505
|
+
</body>
|
|
506
|
+
</html>`;
|
|
507
|
+
const imageBuffer = await this.htmlToImage(fullHtml);
|
|
508
|
+
return imageBuffer ? [imageBuffer] : "图片渲染失败";
|
|
509
|
+
}
|
|
460
510
|
};
|
|
461
511
|
|
|
462
512
|
// src/Stat.ts
|
|
@@ -488,7 +538,7 @@ var Stat = class {
|
|
|
488
538
|
registerCommands(cmd) {
|
|
489
539
|
const createHandler = /* @__PURE__ */ __name((handler) => {
|
|
490
540
|
return async ({ session, options }) => {
|
|
491
|
-
const scope = await this.
|
|
541
|
+
const scope = await parseQueryScope(this.ctx, session, options);
|
|
492
542
|
if (scope.error) return scope.error;
|
|
493
543
|
try {
|
|
494
544
|
const result = await handler(scope, options);
|
|
@@ -504,12 +554,30 @@ var Stat = class {
|
|
|
504
554
|
};
|
|
505
555
|
}, "createHandler");
|
|
506
556
|
if (this.config.enableCmdStat) {
|
|
507
|
-
cmd.subcommand("cmdstat", "命令统计").option("user", "-u <user:string> 指定用户").option("guild", "-g <guildId:string> 指定群组").option("all", "-a 全局").action(createHandler(async (scope) => {
|
|
557
|
+
cmd.subcommand("cmdstat", "命令统计").option("user", "-u <user:string> 指定用户").option("guild", "-g <guildId:string> 指定群组").option("separate", "-h 分离展示").option("all", "-a 全局").action(createHandler(async (scope, options) => {
|
|
508
558
|
const stats = await this.ctx.database.select("analyse_cmd").where({ uid: { $in: scope.uids } }).groupBy("command", { count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count"), lastUsed: /* @__PURE__ */ __name((row) => import_koishi3.$.max(row.timestamp), "lastUsed") }).orderBy("count", "desc").execute();
|
|
509
559
|
if (stats.length === 0) return "暂无统计数据";
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
560
|
+
let processedStats;
|
|
561
|
+
if (options.separate) {
|
|
562
|
+
processedStats = stats;
|
|
563
|
+
} else {
|
|
564
|
+
const mergedStatsMap = /* @__PURE__ */ new Map();
|
|
565
|
+
for (const stat of stats) {
|
|
566
|
+
const mainCommand = stat.command.split(".")[0];
|
|
567
|
+
const existing = mergedStatsMap.get(mainCommand) || { count: 0, lastUsed: /* @__PURE__ */ new Date(0) };
|
|
568
|
+
existing.count += stat.count;
|
|
569
|
+
if (stat.lastUsed > existing.lastUsed) existing.lastUsed = stat.lastUsed;
|
|
570
|
+
mergedStatsMap.set(mainCommand, existing);
|
|
571
|
+
}
|
|
572
|
+
processedStats = Array.from(mergedStatsMap.entries()).map(([command, data]) => ({
|
|
573
|
+
command,
|
|
574
|
+
count: data.count,
|
|
575
|
+
lastUsed: data.lastUsed
|
|
576
|
+
})).sort((a, b) => b.count - a.count);
|
|
577
|
+
}
|
|
578
|
+
const total = processedStats.reduce((sum, record) => sum + record.count, 0);
|
|
579
|
+
const list = processedStats.map((item) => [item.command, item.count, item.lastUsed]);
|
|
580
|
+
const title = await generateTitle(this.ctx, scope.scopeDesc, { main: "命令" });
|
|
513
581
|
return this.renderer.renderList({ title, time: /* @__PURE__ */ new Date(), total, list }, ["命令", "次数", "最后使用"]);
|
|
514
582
|
}));
|
|
515
583
|
}
|
|
@@ -524,7 +592,7 @@ var Stat = class {
|
|
|
524
592
|
if (stats.length === 0) return "暂无统计数据";
|
|
525
593
|
const total = stats.reduce((sum, r) => sum + r.count, 0);
|
|
526
594
|
const list = stats.map((item) => [userNameMap.get(item.uid) || `UID ${item.uid}`, item.count, item.lastUsed]);
|
|
527
|
-
const title = await this.
|
|
595
|
+
const title = await generateTitle(this.ctx, scope.scopeDesc, { main: "发言", subtype: type });
|
|
528
596
|
const headers = type ? ["用户", "条数", "最后发言"] : ["用户", "总计发言", "最后发言"];
|
|
529
597
|
return this.renderer.renderList({ title, time: /* @__PURE__ */ new Date(), total, list }, headers);
|
|
530
598
|
}));
|
|
@@ -542,7 +610,7 @@ var Stat = class {
|
|
|
542
610
|
const total = rankStats.reduce((sum, record) => sum + record.count, 0);
|
|
543
611
|
const list = rankStats.map((item) => [userNameMap.get(item.uid) || `UID ${item.uid}`, item.count]);
|
|
544
612
|
const listWithPercentage = list.map((row) => [...row, total > 0 ? `${(row[1] / total * 100).toFixed(2)}%` : "0.00%"]);
|
|
545
|
-
const title = await this.
|
|
613
|
+
const title = await generateTitle(this.ctx, scope.scopeDesc, { main: "发言排行", timeRange: hours, subtype: type });
|
|
546
614
|
return this.renderer.renderList({ title, time: /* @__PURE__ */ new Date(), total, list: listWithPercentage }, ["用户", "总计发言", "占比"]);
|
|
547
615
|
}));
|
|
548
616
|
}
|
|
@@ -556,57 +624,11 @@ var Stat = class {
|
|
|
556
624
|
hourlyCounts[stat.timestamp.getHours()] += stat.count;
|
|
557
625
|
totalMessages += stat.count;
|
|
558
626
|
});
|
|
559
|
-
const title = await this.
|
|
627
|
+
const title = await generateTitle(this.ctx, scope.scopeDesc, { main: "活跃" });
|
|
560
628
|
return this.renderer.renderCircadianChart({ title, time: /* @__PURE__ */ new Date(), total: totalMessages, data: hourlyCounts });
|
|
561
629
|
}));
|
|
562
630
|
}
|
|
563
631
|
}
|
|
564
|
-
/**
|
|
565
|
-
* @private @method parseQueryScope
|
|
566
|
-
* @description 解析命令选项,转换为包含 UIDs 和描述性信息的统一查询范围对象。
|
|
567
|
-
* @param session - 当前会话对象。
|
|
568
|
-
* @param options - 命令选项。
|
|
569
|
-
* @returns 包含 uids、错误或范围描述的查询范围对象。
|
|
570
|
-
*/
|
|
571
|
-
async parseQueryScope(session, options) {
|
|
572
|
-
const scopeDesc = { guildId: options.guild, userId: void 0 };
|
|
573
|
-
if (options.user) scopeDesc.userId = import_koishi3.h.select(options.user, "at")[0]?.attrs.id ?? options.user.trim();
|
|
574
|
-
if (!options.all && !scopeDesc.guildId && !scopeDesc.userId) scopeDesc.guildId = session.guildId;
|
|
575
|
-
if (!options.all && !scopeDesc.guildId && !scopeDesc.userId) return { error: "请指定查询范围", scopeDesc };
|
|
576
|
-
const query = {};
|
|
577
|
-
if (scopeDesc.guildId) query.channelId = scopeDesc.guildId;
|
|
578
|
-
if (scopeDesc.userId) query.userId = scopeDesc.userId;
|
|
579
|
-
if (Object.keys(query).length === 0) return { uids: void 0, scopeDesc };
|
|
580
|
-
const users = await this.ctx.database.get("analyse_user", query, ["uid"]);
|
|
581
|
-
if (users.length === 0) return { error: "暂无统计数据", scopeDesc };
|
|
582
|
-
return { uids: users.map((u) => u.uid), scopeDesc };
|
|
583
|
-
}
|
|
584
|
-
/**
|
|
585
|
-
* @private @method generateTitle
|
|
586
|
-
* @description 根据查询范围和类型动态生成易于理解的图片标题。
|
|
587
|
-
* @returns 生成的标题字符串。
|
|
588
|
-
*/
|
|
589
|
-
async generateTitle(scopeDesc, options) {
|
|
590
|
-
let guildName = "", userName = "", scopeText = "全局";
|
|
591
|
-
if (scopeDesc.guildId) {
|
|
592
|
-
const [guild] = await this.ctx.database.get("analyse_user", { channelId: scopeDesc.guildId }, ["channelName"]);
|
|
593
|
-
guildName = guild?.channelName || scopeDesc.guildId;
|
|
594
|
-
}
|
|
595
|
-
if (scopeDesc.userId) {
|
|
596
|
-
const [user] = await this.ctx.database.get("analyse_user", { userId: scopeDesc.userId }, ["userName"]);
|
|
597
|
-
userName = user?.userName || scopeDesc.userId;
|
|
598
|
-
}
|
|
599
|
-
const typeText = options.subtype ? `“${options.subtype}”` : "";
|
|
600
|
-
const mainText = options.main;
|
|
601
|
-
if (mainText.includes("排行")) {
|
|
602
|
-
scopeText = guildName || "全局";
|
|
603
|
-
return `${options.timeRange}小时${scopeText}${typeText}${mainText}`;
|
|
604
|
-
}
|
|
605
|
-
if (userName && guildName) scopeText = `${guildName} ${userName}`;
|
|
606
|
-
else if (userName) scopeText = userName;
|
|
607
|
-
else if (guildName) scopeText = guildName;
|
|
608
|
-
return `${scopeText}${typeText}${mainText}统计`;
|
|
609
|
-
}
|
|
610
632
|
};
|
|
611
633
|
|
|
612
634
|
// src/WhoAt.ts
|
|
@@ -799,13 +821,120 @@ var Data = class {
|
|
|
799
821
|
]);
|
|
800
822
|
const uniqueChannels = [...new Map(allChannelInfo.map((item) => [item.channelId, item])).values()];
|
|
801
823
|
const channelOutput = uniqueChannels.length ? "频道列表:\n" + uniqueChannels.map((c) => `[${c.channelId}] ${c.channelName}`).join("\n") : "暂无频道记录";
|
|
802
|
-
const
|
|
824
|
+
const commandNames = commands.map((c) => c.command);
|
|
825
|
+
const commandOutput = commandNames.length ? "命令列表:\n" + commandNames.join(", ") : "暂无命令记录";
|
|
803
826
|
return `${channelOutput}
|
|
804
827
|
${commandOutput}`;
|
|
805
828
|
});
|
|
806
829
|
}
|
|
807
830
|
};
|
|
808
831
|
|
|
832
|
+
// src/Analyse.ts
|
|
833
|
+
var import_koishi6 = require("koishi");
|
|
834
|
+
var import_basic = require("@nlpjs/basic");
|
|
835
|
+
var import_lang_zh = require("@nlpjs/lang-zh");
|
|
836
|
+
var Analyse = class {
|
|
837
|
+
constructor(ctx, config) {
|
|
838
|
+
this.ctx = ctx;
|
|
839
|
+
this.config = config;
|
|
840
|
+
this.renderer = new Renderer(ctx);
|
|
841
|
+
this.nlp = new import_basic.Nlp({ languages: ["zh"], nlu: { log: false } });
|
|
842
|
+
this.nlp.container.register("extract-lang-zh", new import_lang_zh.LangZh());
|
|
843
|
+
this.initializeNlp().catch((err) => {
|
|
844
|
+
this.ctx.logger.error("NLP 语言模型加载失败:", err);
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
static {
|
|
848
|
+
__name(this, "Analyse");
|
|
849
|
+
}
|
|
850
|
+
renderer;
|
|
851
|
+
nlp;
|
|
852
|
+
isNlpReady = false;
|
|
853
|
+
/**
|
|
854
|
+
* @private
|
|
855
|
+
* @method initializeNlp
|
|
856
|
+
* @description 异步加载并训练 NLP 模型。
|
|
857
|
+
*/
|
|
858
|
+
async initializeNlp() {
|
|
859
|
+
await this.nlp.train();
|
|
860
|
+
this.isNlpReady = true;
|
|
861
|
+
}
|
|
862
|
+
registerCommands(cmd) {
|
|
863
|
+
if (this.config.enableWordCloud) {
|
|
864
|
+
cmd.subcommand(".wordcloud", "生成词云", { checkArgCount: false }).usage("基于指定范围内的聊天记录生成词云图。").option("guild", "-g <guildId:string> 指定群组").option("user", "-u <user:string> 指定用户").option("all", "-a 全局").action(async ({ session, options }) => {
|
|
865
|
+
if (!this.isNlpReady) return "文本分析尚未就绪,请稍后再试";
|
|
866
|
+
const scope = await parseQueryScope(this.ctx, session, options);
|
|
867
|
+
if (scope.error) return scope.error;
|
|
868
|
+
const records = await this.ctx.database.select("analyse_cache").where({ uid: { $in: scope.uids } }).project(["content"]).execute();
|
|
869
|
+
if (records.length === 0) return "暂无统计数据";
|
|
870
|
+
const allText = records.map((r) => r.content).join(" ");
|
|
871
|
+
const result = await this.nlp.process("zh", allText);
|
|
872
|
+
const words = result.stems.filter((stem) => stem.length > 1);
|
|
873
|
+
const wordCounts = words.reduce((map, word) => {
|
|
874
|
+
map.set(word, (map.get(word) || 0) + 1);
|
|
875
|
+
return map;
|
|
876
|
+
}, /* @__PURE__ */ new Map());
|
|
877
|
+
if (wordCounts.size === 0) return "暂无有效词语";
|
|
878
|
+
const wordList = Array.from(wordCounts.entries()).sort((a, b) => b[1] - a[1]).slice(0, 150);
|
|
879
|
+
const title = await generateTitle(this.ctx, scope.scopeDesc, { main: "词云" });
|
|
880
|
+
const renderResult = await this.renderer.renderWordCloud({ title, time: /* @__PURE__ */ new Date(), words: wordList });
|
|
881
|
+
if (typeof renderResult === "string") return renderResult;
|
|
882
|
+
if (Array.isArray(renderResult) && renderResult.length > 0) {
|
|
883
|
+
for (const buffer of renderResult) await session.sendQueued(import_koishi6.h.image(buffer, "image/png"));
|
|
884
|
+
}
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
if (this.config.enableVocabulary) {
|
|
888
|
+
cmd.subcommand(".vocabulary", "词汇排行", { checkArgCount: false }).usage("根据不重复词汇量占总词汇量的比例进行排行。").option("guild", "-g <guildId:string> 指定群组").option("all", "-a 全局").action(async ({ session, options }) => {
|
|
889
|
+
if (!this.isNlpReady) return "文本分析尚未就绪,请稍后再试";
|
|
890
|
+
const scope = await parseQueryScope(this.ctx, session, options);
|
|
891
|
+
if (scope.error) return scope.error;
|
|
892
|
+
const users = await this.ctx.database.get("analyse_user", { uid: { $in: scope.uids } }, ["uid", "userName"]);
|
|
893
|
+
const userNameMap = new Map(users.map((u) => [u.uid, u.userName]));
|
|
894
|
+
const allRecords = await this.ctx.database.get("analyse_cache", { uid: { $in: scope.uids } }, ["uid", "content"]);
|
|
895
|
+
if (allRecords.length === 0) return "暂无统计数据";
|
|
896
|
+
const messagesByUid = /* @__PURE__ */ new Map();
|
|
897
|
+
for (const record of allRecords) {
|
|
898
|
+
if (!messagesByUid.has(record.uid)) messagesByUid.set(record.uid, []);
|
|
899
|
+
messagesByUid.get(record.uid).push(record.content);
|
|
900
|
+
}
|
|
901
|
+
const richnessData = [];
|
|
902
|
+
for (const [uid, messages] of messagesByUid.entries()) {
|
|
903
|
+
const allText = messages.join(" ");
|
|
904
|
+
const result = await this.nlp.process("zh", allText);
|
|
905
|
+
const words = result.stems.filter((stem) => stem.length > 1);
|
|
906
|
+
if (words.length < 50) continue;
|
|
907
|
+
const uniqueWords = new Set(words);
|
|
908
|
+
const richness = uniqueWords.size / words.length;
|
|
909
|
+
richnessData.push({
|
|
910
|
+
name: userNameMap.get(uid) || `UID ${uid}`,
|
|
911
|
+
total: words.length,
|
|
912
|
+
unique: uniqueWords.size,
|
|
913
|
+
richness
|
|
914
|
+
});
|
|
915
|
+
}
|
|
916
|
+
if (richnessData.length === 0) return "暂无有效词语";
|
|
917
|
+
richnessData.sort((a, b) => b.richness - a.richness);
|
|
918
|
+
const list = richnessData.map((item) => [
|
|
919
|
+
item.name,
|
|
920
|
+
item.unique,
|
|
921
|
+
item.total,
|
|
922
|
+
`${(item.richness * 100).toFixed(2)}%`
|
|
923
|
+
]);
|
|
924
|
+
const title = await generateTitle(this.ctx, scope.scopeDesc, { main: "词汇排行" });
|
|
925
|
+
const renderResult = await this.renderer.renderList(
|
|
926
|
+
{ title, time: /* @__PURE__ */ new Date(), total: richnessData.length, list },
|
|
927
|
+
["用户", "不重复词数", "总词数", "丰富度"]
|
|
928
|
+
);
|
|
929
|
+
if (typeof renderResult === "string") return renderResult;
|
|
930
|
+
if (Array.isArray(renderResult) && renderResult.length > 0) {
|
|
931
|
+
for (const buffer of renderResult) await session.sendQueued(import_koishi6.h.image(buffer, "image/png"));
|
|
932
|
+
}
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
};
|
|
937
|
+
|
|
809
938
|
// src/index.ts
|
|
810
939
|
var usage = `
|
|
811
940
|
<div style="border-radius: 10px; border: 1px solid #ddd; padding: 16px; margin-bottom: 20px; box-shadow: 0 2px 5px rgba(0,0,0,0.1);">
|
|
@@ -821,37 +950,78 @@ var usage = `
|
|
|
821
950
|
`;
|
|
822
951
|
var name = "chat-analyse";
|
|
823
952
|
var using = ["database", "puppeteer", "cron"];
|
|
824
|
-
var
|
|
825
|
-
|
|
826
|
-
enableListener:
|
|
827
|
-
enableDataIO:
|
|
953
|
+
var Config3 = import_koishi7.Schema.intersect([
|
|
954
|
+
import_koishi7.Schema.object({
|
|
955
|
+
enableListener: import_koishi7.Schema.boolean().default(true).description("启用消息监听"),
|
|
956
|
+
enableDataIO: import_koishi7.Schema.boolean().default(true).description("启用数据管理")
|
|
828
957
|
}).description("杂项配置"),
|
|
829
|
-
|
|
830
|
-
enableCmdStat:
|
|
831
|
-
enableMsgStat:
|
|
832
|
-
enableActivity:
|
|
833
|
-
enableRankStat:
|
|
834
|
-
rankRetentionDays:
|
|
835
|
-
enableWhoAt:
|
|
836
|
-
atRetentionDays:
|
|
958
|
+
import_koishi7.Schema.object({
|
|
959
|
+
enableCmdStat: import_koishi7.Schema.boolean().default(true).description("启用命令统计"),
|
|
960
|
+
enableMsgStat: import_koishi7.Schema.boolean().default(true).description("启用消息统计"),
|
|
961
|
+
enableActivity: import_koishi7.Schema.boolean().default(true).description("启用活跃统计"),
|
|
962
|
+
enableRankStat: import_koishi7.Schema.boolean().default(true).description("启用发言排行"),
|
|
963
|
+
rankRetentionDays: import_koishi7.Schema.number().min(0).default(31).description("排行保留天数"),
|
|
964
|
+
enableWhoAt: import_koishi7.Schema.boolean().default(true).description("启用提及记录"),
|
|
965
|
+
atRetentionDays: import_koishi7.Schema.number().min(0).default(7).description("提及保留天数")
|
|
837
966
|
}).description("基础分析配置"),
|
|
838
|
-
|
|
839
|
-
enableOriRecord:
|
|
967
|
+
import_koishi7.Schema.object({
|
|
968
|
+
enableOriRecord: import_koishi7.Schema.boolean().default(true).description("启用原始记录"),
|
|
969
|
+
enableWordCloud: import_koishi7.Schema.boolean().default(true).description("启用词云生成"),
|
|
970
|
+
enableVocabulary: import_koishi7.Schema.boolean().default(true).description("启用词汇排行")
|
|
840
971
|
}).description("高级分析配置")
|
|
841
972
|
]);
|
|
973
|
+
async function parseQueryScope(ctx, session, options) {
|
|
974
|
+
const scopeDesc = { guildId: options.guild, userId: void 0 };
|
|
975
|
+
if (options.user) scopeDesc.userId = import_koishi7.h.select(options.user, "at")[0]?.attrs.id ?? options.user.trim();
|
|
976
|
+
if (!options.all && !scopeDesc.guildId && !scopeDesc.userId) scopeDesc.guildId = session.guildId;
|
|
977
|
+
if (!options.all && !scopeDesc.guildId && !scopeDesc.userId) return { error: "请指定查询范围", scopeDesc };
|
|
978
|
+
const query = {};
|
|
979
|
+
if (scopeDesc.guildId) query.channelId = scopeDesc.guildId;
|
|
980
|
+
if (scopeDesc.userId) query.userId = scopeDesc.userId;
|
|
981
|
+
if (Object.keys(query).length === 0) return { uids: void 0, scopeDesc };
|
|
982
|
+
const users = await ctx.database.get("analyse_user", query, ["uid"]);
|
|
983
|
+
if (users.length === 0) return { error: "暂无统计数据", scopeDesc };
|
|
984
|
+
return { uids: users.map((u) => u.uid), scopeDesc };
|
|
985
|
+
}
|
|
986
|
+
__name(parseQueryScope, "parseQueryScope");
|
|
987
|
+
async function generateTitle(ctx, scopeDesc, options) {
|
|
988
|
+
let guildName = "", userName = "", scopeText = "全局";
|
|
989
|
+
if (scopeDesc.guildId) {
|
|
990
|
+
const [guild] = await ctx.database.get("analyse_user", { channelId: scopeDesc.guildId }, ["channelName"]);
|
|
991
|
+
guildName = guild?.channelName || scopeDesc.guildId;
|
|
992
|
+
}
|
|
993
|
+
if (scopeDesc.userId) {
|
|
994
|
+
const [user] = await ctx.database.get("analyse_user", { userId: scopeDesc.userId }, ["userName"]);
|
|
995
|
+
userName = user?.userName || scopeDesc.userId;
|
|
996
|
+
}
|
|
997
|
+
const typeText = options.subtype ? `“${options.subtype}”` : "";
|
|
998
|
+
const mainText = options.main;
|
|
999
|
+
if (mainText.includes("排行")) {
|
|
1000
|
+
scopeText = guildName || "全局";
|
|
1001
|
+
return `${options.timeRange}小时${scopeText}${typeText}${mainText}`;
|
|
1002
|
+
}
|
|
1003
|
+
if (userName && guildName) scopeText = `${guildName} ${userName}`;
|
|
1004
|
+
else if (userName) scopeText = userName;
|
|
1005
|
+
else if (guildName) scopeText = guildName;
|
|
1006
|
+
return `${scopeText}${typeText}${mainText}统计`;
|
|
1007
|
+
}
|
|
1008
|
+
__name(generateTitle, "generateTitle");
|
|
842
1009
|
function apply(ctx, config) {
|
|
843
1010
|
if (config.enableListener) new Collector(ctx, config);
|
|
844
1011
|
const analyse = ctx.command("analyse", "数据分析");
|
|
845
1012
|
new Stat(ctx, config).registerCommands(analyse);
|
|
846
1013
|
if (config.enableWhoAt) new WhoAt(ctx, config).registerCommand(analyse);
|
|
847
1014
|
if (config.enableDataIO) new Data(ctx).registerCommands(analyse);
|
|
1015
|
+
if (config.enableOriRecord && (config.enableWordCloud || config.enableVocabulary)) new Analyse(ctx, config).registerCommands(analyse);
|
|
848
1016
|
}
|
|
849
1017
|
__name(apply, "apply");
|
|
850
1018
|
// Annotate the CommonJS export names for ESM import in node:
|
|
851
1019
|
0 && (module.exports = {
|
|
852
1020
|
Config,
|
|
853
1021
|
apply,
|
|
1022
|
+
generateTitle,
|
|
854
1023
|
name,
|
|
1024
|
+
parseQueryScope,
|
|
855
1025
|
usage,
|
|
856
1026
|
using
|
|
857
1027
|
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "koishi-plugin-chat-analyse",
|
|
3
3
|
"description": "聊天记录分析",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.6.0",
|
|
5
5
|
"contributors": [
|
|
6
6
|
"Yis_Rime <yis_rime@outlook.com>"
|
|
7
7
|
],
|
|
@@ -32,10 +32,14 @@
|
|
|
32
32
|
"platform"
|
|
33
33
|
],
|
|
34
34
|
"devDependencies": {
|
|
35
|
-
"koishi-plugin-
|
|
36
|
-
"koishi-plugin-
|
|
35
|
+
"koishi-plugin-cron": "^3.0.0",
|
|
36
|
+
"koishi-plugin-puppeteer": "^3.5.2"
|
|
37
37
|
},
|
|
38
38
|
"peerDependencies": {
|
|
39
39
|
"koishi": "4.18.8"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@nlpjs/basic": "^5.0.0-alpha.5",
|
|
43
|
+
"@nlpjs/lang-zh": "^5.0.0-alpha.5"
|
|
40
44
|
}
|
|
41
45
|
}
|