koishi-plugin-kkk 3.7.2 → 3.9.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/CHANGELOG.md +48 -0
- package/lib/build-metadata.json +5 -5
- package/lib/index.js +8 -3
- package/lib/karin/module/db/bilibili.js +185 -393
- package/lib/karin/module/db/douyin.js +219 -500
- package/lib/karin/module/db/index.js +30 -5
- package/lib/karin/module/db/model.js +200 -0
- package/lib/karin/module/db/statistics.js +197 -242
- package/lib/karin/module/utils/amagiClient.js +40 -15
- package/lib/karin/platform/douyin/douyin.js +10 -0
- package/lib/karin/setup.js +2 -4
- package/package.json +3 -2
- package/scripts/build.mjs +38 -5
- package/scripts/deploy.mjs +76 -27
- package/scripts/smoke-amagi-capability.cjs +97 -0
- package/scripts/smoke-comments.cjs +164 -0
- package/scripts/smoke-db-koishi.cjs +170 -0
- package/src/index.ts +8 -3
- package/src/karin/module/db/bilibili.ts +189 -448
- package/src/karin/module/db/douyin.ts +219 -587
- package/src/karin/module/db/index.ts +30 -4
- package/src/karin/module/db/model.ts +439 -0
- package/src/karin/module/db/statistics.ts +213 -380
- package/src/karin/module/utils/amagiClient.ts +49 -27
- package/src/karin/platform/douyin/douyin.ts +10 -0
- package/src/karin/setup.ts +2 -4
- package/lib/compat/sqlite3.js +0 -117
- package/lib/karin/module/db/migration.js +0 -199
- package/src/compat/sqlite3.ts +0 -123
- package/src/karin/module/db/migration.ts +0 -249
|
@@ -1,18 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
-
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
-
};
|
|
5
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
3
|
exports.StatisticsDBBase = exports.resolveMetricBucket = exports.METRIC_BUCKETS = void 0;
|
|
7
|
-
const
|
|
8
|
-
const node_path_1 = __importDefault(require("node:path"));
|
|
4
|
+
const koishi_1 = require("koishi");
|
|
9
5
|
const node_karin_1 = require("../../../compat/node-karin.js");
|
|
10
|
-
const
|
|
11
|
-
const sqlite3_1 = __importDefault(require("../../../compat/sqlite3.js"));
|
|
12
|
-
// 直接吃 @/root 而不是 @/module/utils 桶:桶会把 ./Common 拖进来,
|
|
13
|
-
// 而 Common 又回头 import @/module(含本文件),形成环。
|
|
14
|
-
const root_2 = require("../../root");
|
|
15
|
-
const migration_1 = require("./migration");
|
|
6
|
+
const model_1 = require("./model");
|
|
16
7
|
/**
|
|
17
8
|
* 指标的分桶边界(左闭右开)。`duration` 单位毫秒。
|
|
18
9
|
*
|
|
@@ -55,29 +46,27 @@ const resolveMetricBucket = (metric, value) => {
|
|
|
55
46
|
return (buckets.find((bucket) => value < bucket.max) ?? buckets[buckets.length - 1]).label;
|
|
56
47
|
};
|
|
57
48
|
exports.resolveMetricBucket = resolveMetricBucket;
|
|
58
|
-
/**
|
|
49
|
+
/** UTC 日期(YYYY-MM-DD),与旧版 sqlite 里 `date('now')` 的口径一致 */
|
|
50
|
+
const today = () => new Date().toISOString().split('T')[0];
|
|
51
|
+
/** 当前时间戳(ISO 字符串) */
|
|
52
|
+
const now = () => new Date().toISOString();
|
|
53
|
+
/** 统计数据库操作类(基于 Koishi 原生数据库服务) */
|
|
59
54
|
class StatisticsDBBase {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
*/
|
|
67
|
-
constructor(dbPath) {
|
|
68
|
-
this.dbPath = dbPath ?? node_path_1.default.join(`${root_1.karinPathBase}/${root_2.Root.pluginName}/data`, 'statistics.db');
|
|
69
|
-
this.migrationManager = new migration_1.MigrationManager(this.dbPath);
|
|
55
|
+
ctx;
|
|
56
|
+
constructor(ctx) {
|
|
57
|
+
this.ctx = ctx;
|
|
58
|
+
}
|
|
59
|
+
get db() {
|
|
60
|
+
return this.ctx.database;
|
|
70
61
|
}
|
|
71
62
|
/**
|
|
72
63
|
* 关闭数据库连接。
|
|
73
|
-
*
|
|
64
|
+
*
|
|
65
|
+
* 现在连接由 Koishi 的数据库服务统一管理,这里不需要做任何事;
|
|
66
|
+
* 保留这个方法只是为了兼容老调用方(测试脚本会调)。
|
|
74
67
|
*/
|
|
75
68
|
async close() {
|
|
76
|
-
|
|
77
|
-
return;
|
|
78
|
-
await new Promise((resolve, reject) => {
|
|
79
|
-
this.db.close((err) => (err ? reject(err) : resolve()));
|
|
80
|
-
});
|
|
69
|
+
/* 由 Koishi 托管,无需手动关闭 */
|
|
81
70
|
}
|
|
82
71
|
/**
|
|
83
72
|
* 初始化数据库
|
|
@@ -85,12 +74,7 @@ class StatisticsDBBase {
|
|
|
85
74
|
async init() {
|
|
86
75
|
try {
|
|
87
76
|
node_karin_1.logger.debug(node_karin_1.logger.green('--------------------------[StatisticsDB] 开始初始化数据库--------------------------'));
|
|
88
|
-
node_karin_1.logger.debug('[StatisticsDB]
|
|
89
|
-
// 创建数据库连接
|
|
90
|
-
node_fs_1.default.mkdirSync(node_path_1.default.dirname(this.dbPath), { recursive: true });
|
|
91
|
-
this.db = new sqlite3_1.default.Database(this.dbPath);
|
|
92
|
-
// 创建表结构
|
|
93
|
-
await this.createTables();
|
|
77
|
+
node_karin_1.logger.debug('[StatisticsDB] 使用 Koishi 原生数据库服务,表已由 ctx.model.extend 注册');
|
|
94
78
|
node_karin_1.logger.debug('[StatisticsDB] 数据库模型同步成功');
|
|
95
79
|
// 初始化全局统计数据
|
|
96
80
|
await this.initGlobalStatistics();
|
|
@@ -104,139 +88,17 @@ class StatisticsDBBase {
|
|
|
104
88
|
}
|
|
105
89
|
return this;
|
|
106
90
|
}
|
|
107
|
-
/**
|
|
108
|
-
* 创建数据库表结构
|
|
109
|
-
*/
|
|
110
|
-
async createTables() {
|
|
111
|
-
const queries = [
|
|
112
|
-
// 创建解析统计表
|
|
113
|
-
`CREATE TABLE IF NOT EXISTS ParseStatistics (
|
|
114
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
115
|
-
groupId TEXT NOT NULL,
|
|
116
|
-
userId TEXT NOT NULL,
|
|
117
|
-
platform TEXT NOT NULL,
|
|
118
|
-
parseCount INTEGER DEFAULT 0,
|
|
119
|
-
createdAt TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
120
|
-
updatedAt TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
121
|
-
UNIQUE(groupId, userId, platform)
|
|
122
|
-
)`,
|
|
123
|
-
// 创建解析历史表
|
|
124
|
-
`CREATE TABLE IF NOT EXISTS ParseHistory (
|
|
125
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
126
|
-
date TEXT NOT NULL UNIQUE,
|
|
127
|
-
totalParses INTEGER DEFAULT 0,
|
|
128
|
-
douyin INTEGER DEFAULT 0,
|
|
129
|
-
bilibili INTEGER DEFAULT 0,
|
|
130
|
-
kuaishou INTEGER DEFAULT 0,
|
|
131
|
-
xiaohongshu INTEGER DEFAULT 0,
|
|
132
|
-
createdAt TEXT DEFAULT CURRENT_TIMESTAMP
|
|
133
|
-
)`,
|
|
134
|
-
// 创建全局统计表
|
|
135
|
-
`CREATE TABLE IF NOT EXISTS GlobalStatistics (
|
|
136
|
-
key TEXT PRIMARY KEY,
|
|
137
|
-
value TEXT NOT NULL,
|
|
138
|
-
updatedAt TEXT DEFAULT CURRENT_TIMESTAMP
|
|
139
|
-
)`,
|
|
140
|
-
// 群维度日粒度:ParseHistory 只有全局日粒度,画不出单群趋势
|
|
141
|
-
`CREATE TABLE IF NOT EXISTS GroupParseHistory (
|
|
142
|
-
groupId TEXT NOT NULL,
|
|
143
|
-
date TEXT NOT NULL,
|
|
144
|
-
platform TEXT NOT NULL,
|
|
145
|
-
parseCount INTEGER DEFAULT 0,
|
|
146
|
-
updatedAt TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
147
|
-
PRIMARY KEY (groupId, date, platform)
|
|
148
|
-
)`,
|
|
149
|
-
// 小时粒度活跃分布:行数有界(群数 × 24 × 4),可以按小时永久聚合
|
|
150
|
-
`CREATE TABLE IF NOT EXISTS ParseHourStats (
|
|
151
|
-
groupId TEXT NOT NULL,
|
|
152
|
-
hour INTEGER NOT NULL,
|
|
153
|
-
platform TEXT NOT NULL,
|
|
154
|
-
parseCount INTEGER DEFAULT 0,
|
|
155
|
-
updatedAt TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
156
|
-
PRIMARY KEY (groupId, hour, platform)
|
|
157
|
-
)`,
|
|
158
|
-
// 内容形态分布:支撑「平台 × 形态」图
|
|
159
|
-
`CREATE TABLE IF NOT EXISTS ParseWorkTypeStats (
|
|
160
|
-
groupId TEXT NOT NULL,
|
|
161
|
-
platform TEXT NOT NULL,
|
|
162
|
-
workType TEXT NOT NULL,
|
|
163
|
-
parseCount INTEGER DEFAULT 0,
|
|
164
|
-
updatedAt TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
165
|
-
PRIMARY KEY (groupId, platform, workType)
|
|
166
|
-
)`,
|
|
167
|
-
// 解析耗时 / 作品时长 / 作品点赞的分桶分布。
|
|
168
|
-
// 存桶而不是原值:原值每次解析都不同,行数会随解析次数无限涨;
|
|
169
|
-
// 分桶后行数上界是 群数 × 指标数 × 桶数。
|
|
170
|
-
`CREATE TABLE IF NOT EXISTS ParseMetricStats (
|
|
171
|
-
groupId TEXT NOT NULL,
|
|
172
|
-
metric TEXT NOT NULL,
|
|
173
|
-
bucket TEXT NOT NULL,
|
|
174
|
-
parseCount INTEGER DEFAULT 0,
|
|
175
|
-
updatedAt TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
176
|
-
PRIMARY KEY (groupId, metric, bucket)
|
|
177
|
-
)`
|
|
178
|
-
];
|
|
179
|
-
for (const query of queries) {
|
|
180
|
-
await this.runQuery(query);
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
91
|
/**
|
|
184
92
|
* 初始化全局统计数据
|
|
185
93
|
*/
|
|
186
94
|
async initGlobalStatistics() {
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
}
|
|
95
|
+
for (const key of ['totalGroups', 'totalParses']) {
|
|
96
|
+
const [row] = await this.db.get(model_1.TABLE.statsGlobal, { key });
|
|
97
|
+
if (row)
|
|
98
|
+
continue;
|
|
99
|
+
await this.db.create(model_1.TABLE.statsGlobal, { key, value: 0, updatedAt: now() });
|
|
193
100
|
}
|
|
194
101
|
}
|
|
195
|
-
/**
|
|
196
|
-
* 执行SQL查询
|
|
197
|
-
*/
|
|
198
|
-
runQuery(sql, params = []) {
|
|
199
|
-
return new Promise((resolve, reject) => {
|
|
200
|
-
this.db.run(sql, params, function (err) {
|
|
201
|
-
if (err) {
|
|
202
|
-
reject(err);
|
|
203
|
-
}
|
|
204
|
-
else {
|
|
205
|
-
resolve({ lastID: this.lastID, changes: this.changes });
|
|
206
|
-
}
|
|
207
|
-
});
|
|
208
|
-
});
|
|
209
|
-
}
|
|
210
|
-
/**
|
|
211
|
-
* 执行SQL查询并获取单个结果
|
|
212
|
-
*/
|
|
213
|
-
getQuery(sql, params = []) {
|
|
214
|
-
return new Promise((resolve, reject) => {
|
|
215
|
-
this.db.get(sql, params, (err, row) => {
|
|
216
|
-
if (err) {
|
|
217
|
-
reject(err);
|
|
218
|
-
}
|
|
219
|
-
else {
|
|
220
|
-
resolve(row);
|
|
221
|
-
}
|
|
222
|
-
});
|
|
223
|
-
});
|
|
224
|
-
}
|
|
225
|
-
/**
|
|
226
|
-
* 执行SQL查询并获取所有结果
|
|
227
|
-
*/
|
|
228
|
-
allQuery(sql, params = []) {
|
|
229
|
-
return new Promise((resolve, reject) => {
|
|
230
|
-
this.db.all(sql, params, (err, rows) => {
|
|
231
|
-
if (err) {
|
|
232
|
-
reject(err);
|
|
233
|
-
}
|
|
234
|
-
else {
|
|
235
|
-
resolve(rows);
|
|
236
|
-
}
|
|
237
|
-
});
|
|
238
|
-
});
|
|
239
|
-
}
|
|
240
102
|
/**
|
|
241
103
|
* 记录解析统计
|
|
242
104
|
* @param groupId 群组ID
|
|
@@ -246,30 +108,40 @@ class StatisticsDBBase {
|
|
|
246
108
|
* @param options.durationMs 本次解析耗时(毫秒)
|
|
247
109
|
*/
|
|
248
110
|
async recordParse(groupId, userId, platform, options = {}) {
|
|
249
|
-
const
|
|
250
|
-
const
|
|
111
|
+
const time = now();
|
|
112
|
+
const date = today();
|
|
251
113
|
// 检查是否已存在该用户在该群组的统计记录
|
|
252
|
-
const existing = await this.
|
|
114
|
+
const [existing] = await this.db.get(model_1.TABLE.statsParse, { groupId, userId, platform });
|
|
253
115
|
if (existing) {
|
|
254
116
|
// 更新解析次数
|
|
255
|
-
await this.
|
|
117
|
+
await this.db.set(model_1.TABLE.statsParse, { groupId, userId, platform }, (row) => ({
|
|
118
|
+
parseCount: koishi_1.$.add(koishi_1.$.ifNull(row.parseCount, 0), 1),
|
|
119
|
+
updatedAt: time
|
|
120
|
+
}));
|
|
256
121
|
}
|
|
257
122
|
else {
|
|
258
123
|
// 创建新记录
|
|
259
|
-
await this.
|
|
124
|
+
await this.db.create(model_1.TABLE.statsParse, {
|
|
125
|
+
groupId,
|
|
126
|
+
userId,
|
|
127
|
+
platform,
|
|
128
|
+
parseCount: 1,
|
|
129
|
+
createdAt: time,
|
|
130
|
+
updatedAt: time
|
|
131
|
+
});
|
|
260
132
|
// 检查是否是新群组
|
|
261
|
-
const
|
|
262
|
-
if (
|
|
133
|
+
const groupRows = await this.db.get(model_1.TABLE.statsParse, { groupId }, { limit: 2 });
|
|
134
|
+
if (groupRows.length === 1) {
|
|
263
135
|
await this.incrementTotalGroups();
|
|
264
136
|
}
|
|
265
137
|
}
|
|
266
138
|
// 更新总解析次数
|
|
267
139
|
await this.incrementTotalParses();
|
|
268
140
|
// 更新每日历史记录
|
|
269
|
-
await this.updateDailyHistory(
|
|
141
|
+
await this.updateDailyHistory(date, platform);
|
|
270
142
|
// 以下是本次新增的维度。任何一张写失败都不该让解析主流程或其它维度受影响,
|
|
271
143
|
// 所以逐条兜住,只记日志。
|
|
272
|
-
await this.safeIncrement('群维度日粒度', () => this.incrementGroupHistory(groupId,
|
|
144
|
+
await this.safeIncrement('群维度日粒度', () => this.incrementGroupHistory(groupId, date, platform));
|
|
273
145
|
await this.safeIncrement('活跃时段', () => this.incrementHourStats(groupId, new Date().getHours(), platform));
|
|
274
146
|
if (options.workType) {
|
|
275
147
|
await this.safeIncrement('内容形态', () => this.incrementWorkTypeStats(groupId, platform, options.workType));
|
|
@@ -295,29 +167,49 @@ class StatisticsDBBase {
|
|
|
295
167
|
* 群维度日粒度自增
|
|
296
168
|
*/
|
|
297
169
|
async incrementGroupHistory(groupId, date, platform) {
|
|
298
|
-
await this.
|
|
299
|
-
|
|
170
|
+
await this.db.upsert(model_1.TABLE.statsGroupHistory, (row) => [{
|
|
171
|
+
groupId,
|
|
172
|
+
date,
|
|
173
|
+
platform,
|
|
174
|
+
parseCount: koishi_1.$.add(koishi_1.$.ifNull(row.parseCount, 0), 1),
|
|
175
|
+
updatedAt: now()
|
|
176
|
+
}]);
|
|
300
177
|
}
|
|
301
178
|
/**
|
|
302
179
|
* 小时粒度自增
|
|
303
180
|
*/
|
|
304
181
|
async incrementHourStats(groupId, hour, platform) {
|
|
305
|
-
await this.
|
|
306
|
-
|
|
182
|
+
await this.db.upsert(model_1.TABLE.statsHour, (row) => [{
|
|
183
|
+
groupId,
|
|
184
|
+
hour,
|
|
185
|
+
platform,
|
|
186
|
+
parseCount: koishi_1.$.add(koishi_1.$.ifNull(row.parseCount, 0), 1),
|
|
187
|
+
updatedAt: now()
|
|
188
|
+
}]);
|
|
307
189
|
}
|
|
308
190
|
/**
|
|
309
191
|
* 内容形态自增
|
|
310
192
|
*/
|
|
311
193
|
async incrementWorkTypeStats(groupId, platform, workType) {
|
|
312
|
-
await this.
|
|
313
|
-
|
|
194
|
+
await this.db.upsert(model_1.TABLE.statsWorkType, (row) => [{
|
|
195
|
+
groupId,
|
|
196
|
+
platform,
|
|
197
|
+
workType,
|
|
198
|
+
parseCount: koishi_1.$.add(koishi_1.$.ifNull(row.parseCount, 0), 1),
|
|
199
|
+
updatedAt: now()
|
|
200
|
+
}]);
|
|
314
201
|
}
|
|
315
202
|
/**
|
|
316
203
|
* 指标分桶自增
|
|
317
204
|
*/
|
|
318
205
|
async incrementMetricStats(groupId, metric, bucket) {
|
|
319
|
-
await this.
|
|
320
|
-
|
|
206
|
+
await this.db.upsert(model_1.TABLE.statsMetric, (row) => [{
|
|
207
|
+
groupId,
|
|
208
|
+
metric,
|
|
209
|
+
bucket,
|
|
210
|
+
parseCount: koishi_1.$.add(koishi_1.$.ifNull(row.parseCount, 0), 1),
|
|
211
|
+
updatedAt: now()
|
|
212
|
+
}]);
|
|
321
213
|
}
|
|
322
214
|
/**
|
|
323
215
|
* 更新每日历史记录
|
|
@@ -325,21 +217,24 @@ class StatisticsDBBase {
|
|
|
325
217
|
* @param platform 平台类型
|
|
326
218
|
*/
|
|
327
219
|
async updateDailyHistory(date, platform) {
|
|
328
|
-
const now = new Date().toISOString();
|
|
329
220
|
try {
|
|
330
|
-
const existing = await this.
|
|
221
|
+
const [existing] = await this.db.get(model_1.TABLE.statsHistory, { date });
|
|
331
222
|
if (existing) {
|
|
332
|
-
await this.
|
|
223
|
+
await this.db.set(model_1.TABLE.statsHistory, { date }, (row) => ({
|
|
224
|
+
totalParses: koishi_1.$.add(koishi_1.$.ifNull(row.totalParses, 0), 1),
|
|
225
|
+
[platform]: koishi_1.$.add(koishi_1.$.ifNull(row[platform], 0), 1)
|
|
226
|
+
}));
|
|
333
227
|
}
|
|
334
228
|
else {
|
|
335
|
-
await this.
|
|
229
|
+
await this.db.create(model_1.TABLE.statsHistory, {
|
|
336
230
|
date,
|
|
337
|
-
|
|
338
|
-
platform === '
|
|
339
|
-
platform === '
|
|
340
|
-
platform === '
|
|
341
|
-
|
|
342
|
-
|
|
231
|
+
totalParses: 1,
|
|
232
|
+
douyin: platform === 'douyin' ? 1 : 0,
|
|
233
|
+
bilibili: platform === 'bilibili' ? 1 : 0,
|
|
234
|
+
kuaishou: platform === 'kuaishou' ? 1 : 0,
|
|
235
|
+
xiaohongshu: platform === 'xiaohongshu' ? 1 : 0,
|
|
236
|
+
createdAt: now()
|
|
237
|
+
});
|
|
343
238
|
}
|
|
344
239
|
}
|
|
345
240
|
catch (error) {
|
|
@@ -351,7 +246,10 @@ class StatisticsDBBase {
|
|
|
351
246
|
* @param days 天数,默认30天
|
|
352
247
|
*/
|
|
353
248
|
async getRecentHistory(days = 30) {
|
|
354
|
-
return await this.
|
|
249
|
+
return (await this.db.get(model_1.TABLE.statsHistory, {}, {
|
|
250
|
+
sort: { date: 'desc' },
|
|
251
|
+
limit: days
|
|
252
|
+
}));
|
|
355
253
|
}
|
|
356
254
|
/**
|
|
357
255
|
* 从现有统计数据同步历史记录(用于迁移后的数据修复)
|
|
@@ -359,32 +257,36 @@ class StatisticsDBBase {
|
|
|
359
257
|
async syncHistoryFromStats() {
|
|
360
258
|
try {
|
|
361
259
|
// 检查 ParseHistory 表是否为空
|
|
362
|
-
const
|
|
260
|
+
const historyRows = await this.db.get(model_1.TABLE.statsHistory, {}, { limit: 1 });
|
|
363
261
|
// 如果已有历史数据,不需要同步
|
|
364
|
-
if (
|
|
262
|
+
if (historyRows.length)
|
|
365
263
|
return;
|
|
366
|
-
}
|
|
367
264
|
// 获取所有统计数据
|
|
368
|
-
const allStats = await this.
|
|
265
|
+
const allStats = await this.db.get(model_1.TABLE.statsParse, {});
|
|
369
266
|
// 按日期和平台聚合
|
|
370
267
|
const dateMap = new Map();
|
|
371
268
|
for (const stat of allStats) {
|
|
372
269
|
const date = stat.createdAt.split('T')[0];
|
|
373
270
|
if (!dateMap.has(date)) {
|
|
374
|
-
dateMap.set(date, {
|
|
375
|
-
douyin: 0,
|
|
376
|
-
bilibili: 0,
|
|
377
|
-
kuaishou: 0,
|
|
378
|
-
xiaohongshu: 0
|
|
379
|
-
});
|
|
271
|
+
dateMap.set(date, { douyin: 0, bilibili: 0, kuaishou: 0, xiaohongshu: 0 });
|
|
380
272
|
}
|
|
381
273
|
const dateData = dateMap.get(date);
|
|
382
|
-
|
|
274
|
+
if (stat.platform in dateData) {
|
|
275
|
+
dateData[stat.platform] += stat.parseCount ?? 0;
|
|
276
|
+
}
|
|
383
277
|
}
|
|
384
278
|
// 插入历史记录
|
|
385
279
|
for (const [date, platforms] of dateMap.entries()) {
|
|
386
280
|
const totalParses = platforms.douyin + platforms.bilibili + platforms.kuaishou + platforms.xiaohongshu;
|
|
387
|
-
await this.
|
|
281
|
+
await this.db.create(model_1.TABLE.statsHistory, {
|
|
282
|
+
date,
|
|
283
|
+
totalParses,
|
|
284
|
+
douyin: platforms.douyin,
|
|
285
|
+
bilibili: platforms.bilibili,
|
|
286
|
+
kuaishou: platforms.kuaishou,
|
|
287
|
+
xiaohongshu: platforms.xiaohongshu,
|
|
288
|
+
createdAt: now()
|
|
289
|
+
});
|
|
388
290
|
}
|
|
389
291
|
node_karin_1.logger.info(`[StatisticsDB] 已同步 ${dateMap.size} 天的历史数据`);
|
|
390
292
|
}
|
|
@@ -397,145 +299,185 @@ class StatisticsDBBase {
|
|
|
397
299
|
* @param groupId 群组ID
|
|
398
300
|
*/
|
|
399
301
|
async getGroupStatistics(groupId) {
|
|
400
|
-
return await this.
|
|
302
|
+
return (await this.db.get(model_1.TABLE.statsParse, { groupId }, {
|
|
303
|
+
sort: { platform: 'asc', userId: 'asc' }
|
|
304
|
+
}));
|
|
401
305
|
}
|
|
402
306
|
/**
|
|
403
307
|
* 获取群组的唯一用户数
|
|
404
308
|
* @param groupId 群组ID
|
|
405
309
|
*/
|
|
406
310
|
async getGroupUniqueUsers(groupId) {
|
|
407
|
-
const
|
|
408
|
-
|
|
409
|
-
]);
|
|
410
|
-
return result?.count || 0;
|
|
311
|
+
const rows = await this.db.get(model_1.TABLE.statsParse, { groupId });
|
|
312
|
+
return new Set(rows.map((row) => row.userId)).size;
|
|
411
313
|
}
|
|
412
314
|
/**
|
|
413
315
|
* 获取全局唯一用户数
|
|
414
316
|
*/
|
|
415
317
|
async getTotalUniqueUsers() {
|
|
416
|
-
const
|
|
417
|
-
return
|
|
318
|
+
const rows = await this.db.get(model_1.TABLE.statsParse, {});
|
|
319
|
+
return new Set(rows.map((row) => row.userId)).size;
|
|
418
320
|
}
|
|
419
321
|
/**
|
|
420
322
|
* 获取所有群组的解析统计
|
|
421
323
|
*/
|
|
422
324
|
async getAllStatistics() {
|
|
423
|
-
return await this.
|
|
325
|
+
return (await this.db.get(model_1.TABLE.statsParse, {}, {
|
|
326
|
+
sort: { groupId: 'asc', platform: 'asc' }
|
|
327
|
+
}));
|
|
424
328
|
}
|
|
425
329
|
/**
|
|
426
330
|
* 获取平台总解析次数
|
|
427
331
|
* @param platform 平台类型
|
|
428
332
|
*/
|
|
429
333
|
async getPlatformTotalParses(platform) {
|
|
430
|
-
const
|
|
431
|
-
|
|
432
|
-
]);
|
|
433
|
-
return result?.total || 0;
|
|
334
|
+
const rows = await this.db.get(model_1.TABLE.statsParse, { platform });
|
|
335
|
+
return rows.reduce((sum, row) => sum + (Number(row.parseCount) || 0), 0);
|
|
434
336
|
}
|
|
435
337
|
/**
|
|
436
338
|
* 获取某个群最近 N 天的日粒度记录(按日期升序,便于直接喂折线图)
|
|
437
339
|
*
|
|
438
|
-
* 日期口径与写入端一致,都取 `toISOString()` 的 UTC
|
|
439
|
-
* SQLite 的 `date('now')` 同样是 UTC,两边不会错位。
|
|
340
|
+
* 日期口径与写入端一致,都取 `toISOString()` 的 UTC 日期。
|
|
440
341
|
* @param groupId 群组ID
|
|
441
342
|
* @param days 天数,默认 30 天
|
|
442
343
|
*/
|
|
443
344
|
async getGroupRecentHistory(groupId, days = 30) {
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
345
|
+
const from = new Date();
|
|
346
|
+
from.setUTCDate(from.getUTCDate() - Math.max(0, days - 1));
|
|
347
|
+
const cutoff = from.toISOString().split('T')[0];
|
|
348
|
+
return (await this.db.get(model_1.TABLE.statsGroupHistory, {
|
|
349
|
+
groupId,
|
|
350
|
+
date: { $gte: cutoff }
|
|
351
|
+
}, { sort: { date: 'asc' } }));
|
|
447
352
|
}
|
|
448
353
|
/**
|
|
449
354
|
* 获取某个群有解析记录的天数(不限于最近 N 天)
|
|
450
355
|
* @param groupId 群组ID
|
|
451
356
|
*/
|
|
452
357
|
async getGroupActiveDays(groupId) {
|
|
453
|
-
const
|
|
454
|
-
return
|
|
358
|
+
const rows = await this.db.get(model_1.TABLE.statsGroupHistory, { groupId });
|
|
359
|
+
return new Set(rows.map((row) => row.date)).size;
|
|
455
360
|
}
|
|
456
361
|
/**
|
|
457
362
|
* 获取某个群的小时粒度分布(0-23,缺失的小时由调用方补零)
|
|
458
363
|
* @param groupId 群组ID
|
|
459
364
|
*/
|
|
460
365
|
async getGroupHourStats(groupId) {
|
|
461
|
-
return await this.
|
|
366
|
+
return (await this.db.get(model_1.TABLE.statsHour, { groupId }, { sort: { hour: 'asc' } }));
|
|
462
367
|
}
|
|
463
368
|
/**
|
|
464
369
|
* 获取某个群的内容形态分布
|
|
465
370
|
* @param groupId 群组ID
|
|
466
371
|
*/
|
|
467
372
|
async getGroupWorkTypeStats(groupId) {
|
|
468
|
-
return await this.
|
|
373
|
+
return (await this.db.get(model_1.TABLE.statsWorkType, { groupId }, {
|
|
374
|
+
sort: { parseCount: 'desc' }
|
|
375
|
+
}));
|
|
469
376
|
}
|
|
470
377
|
/**
|
|
471
378
|
* 获取全局内容形态分布(按平台 × 形态聚合)
|
|
472
379
|
*/
|
|
473
380
|
async getGlobalWorkTypeStats() {
|
|
474
|
-
|
|
475
|
-
|
|
381
|
+
const rows = await this.db.get(model_1.TABLE.statsWorkType, {});
|
|
382
|
+
const map = new Map();
|
|
383
|
+
for (const row of rows) {
|
|
384
|
+
const key = row.platform + '' + row.workType;
|
|
385
|
+
const current = map.get(key);
|
|
386
|
+
if (current) {
|
|
387
|
+
current.parseCount += Number(row.parseCount) || 0;
|
|
388
|
+
if ((row.updatedAt ?? '') > (current.updatedAt ?? ''))
|
|
389
|
+
current.updatedAt = row.updatedAt;
|
|
390
|
+
}
|
|
391
|
+
else {
|
|
392
|
+
map.set(key, { ...row, parseCount: Number(row.parseCount) || 0 });
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return [...map.values()].sort((a, b) => {
|
|
396
|
+
if (a.platform !== b.platform)
|
|
397
|
+
return a.platform < b.platform ? -1 : 1;
|
|
398
|
+
return (Number(b.parseCount) || 0) - (Number(a.parseCount) || 0);
|
|
399
|
+
});
|
|
476
400
|
}
|
|
477
401
|
/**
|
|
478
402
|
* 获取某个群的指标分桶分布(耗时 / 作品时长 / 点赞)
|
|
479
403
|
* @param groupId 群组ID
|
|
480
404
|
*/
|
|
481
405
|
async getGroupMetricStats(groupId) {
|
|
482
|
-
|
|
406
|
+
const rows = await this.db.get(model_1.TABLE.statsMetric, { groupId });
|
|
407
|
+
return aggregateMetrics(rows);
|
|
483
408
|
}
|
|
484
409
|
/**
|
|
485
410
|
* 获取全局指标分桶分布(耗时 / 作品时长 / 点赞)
|
|
486
411
|
*/
|
|
487
412
|
async getGlobalMetricStats() {
|
|
488
|
-
|
|
413
|
+
const rows = await this.db.get(model_1.TABLE.statsMetric, {});
|
|
414
|
+
return aggregateMetrics(rows);
|
|
489
415
|
}
|
|
490
416
|
/**
|
|
491
417
|
* 取全局日粒度趋势里「数据可信」的起始日期。
|
|
492
418
|
* @returns 可信起始日(YYYY-MM-DD);一行可信数据都没有时返回 undefined
|
|
493
419
|
*/
|
|
494
420
|
async getHistoryCompleteFrom() {
|
|
421
|
+
// 老库回填(syncHistoryFromStats)会把用户的历史总次数全记在他首次解析那天,
|
|
495
422
|
// 那批行的特征是 date 与写入时间 createdAt 不在同一天;
|
|
496
423
|
// 增量写入的行两者必然同天(都取 UTC)。据此切出可信区间的起点。
|
|
497
|
-
const
|
|
498
|
-
|
|
424
|
+
const rows = await this.db.get(model_1.TABLE.statsHistory, {});
|
|
425
|
+
let min;
|
|
426
|
+
for (const row of rows) {
|
|
427
|
+
if (!row.date || row.date !== String(row.createdAt ?? '').slice(0, 10))
|
|
428
|
+
continue;
|
|
429
|
+
if (!min || row.date < min)
|
|
430
|
+
min = row.date;
|
|
431
|
+
}
|
|
432
|
+
return min;
|
|
499
433
|
}
|
|
500
434
|
/**
|
|
501
435
|
* 获取每个群首次被记录解析的日期(群增长曲线的数据源)
|
|
502
436
|
*/
|
|
503
437
|
async getGroupFirstSeen() {
|
|
504
|
-
|
|
438
|
+
const rows = await this.db.get(model_1.TABLE.statsParse, {});
|
|
439
|
+
const map = new Map();
|
|
440
|
+
for (const row of rows) {
|
|
441
|
+
if (!row.createdAt)
|
|
442
|
+
continue;
|
|
443
|
+
const current = map.get(row.groupId);
|
|
444
|
+
if (!current || row.createdAt < current)
|
|
445
|
+
map.set(row.groupId, row.createdAt);
|
|
446
|
+
}
|
|
447
|
+
return [...map.entries()].map(([groupId, firstSeen]) => ({ groupId, firstSeen }));
|
|
505
448
|
}
|
|
506
449
|
/**
|
|
507
450
|
* 获取总群组数
|
|
508
451
|
*/
|
|
509
452
|
async getTotalGroups() {
|
|
510
|
-
const
|
|
511
|
-
return
|
|
453
|
+
const rows = await this.db.get(model_1.TABLE.statsParse, {});
|
|
454
|
+
return new Set(rows.map((row) => row.groupId)).size;
|
|
512
455
|
}
|
|
513
456
|
/**
|
|
514
457
|
* 获取总解析次数
|
|
515
458
|
*/
|
|
516
459
|
async getTotalParses() {
|
|
517
|
-
const
|
|
518
|
-
return
|
|
460
|
+
const [row] = await this.db.get(model_1.TABLE.statsGlobal, { key: 'totalParses' });
|
|
461
|
+
return Number(row?.value ?? 0) || 0;
|
|
519
462
|
}
|
|
520
463
|
/**
|
|
521
464
|
* 增加总群组数
|
|
522
465
|
*/
|
|
523
466
|
async incrementTotalGroups() {
|
|
524
467
|
const totalGroups = await this.getTotalGroups();
|
|
525
|
-
await this.
|
|
526
|
-
totalGroups
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
]);
|
|
468
|
+
await this.db.set(model_1.TABLE.statsGlobal, { key: 'totalGroups' }, {
|
|
469
|
+
value: totalGroups,
|
|
470
|
+
updatedAt: now()
|
|
471
|
+
});
|
|
530
472
|
}
|
|
531
473
|
/**
|
|
532
474
|
* 增加总解析次数
|
|
533
475
|
*/
|
|
534
476
|
async incrementTotalParses() {
|
|
535
|
-
await this.
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
477
|
+
await this.db.set(model_1.TABLE.statsGlobal, { key: 'totalParses' }, (row) => ({
|
|
478
|
+
value: koishi_1.$.add(koishi_1.$.ifNull(row.value, 0), 1),
|
|
479
|
+
updatedAt: now()
|
|
480
|
+
}));
|
|
539
481
|
}
|
|
540
482
|
/**
|
|
541
483
|
* 获取全局统计摘要
|
|
@@ -557,3 +499,16 @@ class StatisticsDBBase {
|
|
|
557
499
|
}
|
|
558
500
|
}
|
|
559
501
|
exports.StatisticsDBBase = StatisticsDBBase;
|
|
502
|
+
/** 把指标分桶行按 `metric + bucket` 聚合求和 */
|
|
503
|
+
function aggregateMetrics(rows) {
|
|
504
|
+
const map = new Map();
|
|
505
|
+
for (const row of rows) {
|
|
506
|
+
const key = row.metric + '' + row.bucket;
|
|
507
|
+
const current = map.get(key);
|
|
508
|
+
if (current)
|
|
509
|
+
current.count += Number(row.parseCount) || 0;
|
|
510
|
+
else
|
|
511
|
+
map.set(key, { metric: row.metric, bucket: row.bucket, count: Number(row.parseCount) || 0 });
|
|
512
|
+
}
|
|
513
|
+
return [...map.values()];
|
|
514
|
+
}
|