UNPKG

koishi-plugin-statistical-ranking

Version:

统计所有命令使用和成员发言记录,支持筛选展示列表,可以切换文本和图片两种展示形式

1,283 lines (1,276 loc) 73.2 kB
var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __name = (target, value) => __defProp(target, "name", { value, configurable: true }); var __export = (target, all) => { for (var name2 in all) __defProp(target, name2, { get: all[name2], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( // If the importer is in node compatibility mode or this is not an ESM // file that has been converted to a CommonJS file using a Babel- // compatible transform (i.e. "__esModule" has not been set), then set // "default" to the CommonJS "module.exports" for node compatibility. isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod )); var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // src/index.ts var src_exports = {}; __export(src_exports, { Config: () => Config, apply: () => apply, inject: () => inject, name: () => name }); module.exports = __toCommonJS(src_exports); var import_koishi = require("koishi"); // src/utils.ts var fs = __toESM(require("fs")); var path = __toESM(require("path")); var Utils = { /** * 获取字符串显示宽度(中文字符计为2,其他字符计为1) * @param {string} str - 输入字符串 * @returns {number} 字符串显示宽度 */ getStringDisplayWidth(str) { if (!str) return 0; return Array.from(str).reduce((w, c) => w + (/[\u3000-\u9fff\uff01-\uff60\u2E80-\u2FDF\u3040-\u30FF\u2600-\u26FF\u2700-\u27BF]/.test(c) ? 2 : 1), 0); }, /** * 按显示宽度截断字符串 * @param {string} str - 输入字符串 * @param {number} maxWidth - 最大显示宽度 * @returns {string} 截断后的字符串 */ truncateByDisplayWidth(str, maxWidth) { if (!str) return str; let width = 0, result = ""; for (const char of Array.from(str)) { const charWidth = /[\u3000-\u9fff\uff01-\uff60\u2E80-\u2FDF\u3040-\u30FF\u2600-\u26FF\u2700-\u27BF]/.test(char) ? 2 : 1; if (width + charWidth > maxWidth) break; width += charWidth; result += char; } return result; }, /** * 清理字符串,移除不可见字符和特殊字符,限制长度 * @param {string} input - 输入字符串 * @returns {string} 清理后的字符串 */ sanitizeString(input) { if (input == null) return ""; return String(input).replace(/[\x00-\x1F\x7F\u200B-\u200F\u2028-\u202F\uFEFF]/g, "").replace(/(.)\1{3,}/g, "$1$1$1…").replace(/[<>`$()[\]{};'"\\\=]/g, "").replace(/\s+/g, " ").trim().slice(0, 64); }, /** * 格式化时间为"多久前"的形式 * @param {Date} date - 日期对象 * @returns {string} 格式化后的时间字符串 */ formatTimeAgo(date) { if (!date?.getTime?.()) return "未知时间"; const diff = Date.now() - date.getTime(); if (Math.abs(diff) < 3e3) return diff < 0 ? "一会后" : "一会前"; const units = [ [31536e6, "年"], [2592e6, "月"], [864e5, "天"], [36e5, "时"], [6e4, "分"], [1e3, "秒"] ]; const absDiff = Math.abs(diff); const suffix = diff < 0 ? "后" : "前"; for (let i = 0; i < units.length; i++) { const [primaryDiv, primaryUnit] = units[i]; if (absDiff < primaryDiv) continue; const primaryVal = Math.floor(absDiff / primaryDiv); if (i < units.length - 1) { const [secondaryDiv, secondaryUnit] = units[i + 1]; const remainder = absDiff % primaryDiv; const secondaryVal = Math.floor(remainder / secondaryDiv); if (secondaryVal > 0) { return `${primaryVal}${primaryUnit}${secondaryVal}${secondaryUnit}${suffix}`; } } return `${primaryVal}${primaryUnit}${suffix}`; } return `一会${suffix}`; }, /** * 格式化日期时间为年月日和24小时制 * @param {Date} date - 日期对象 * @returns {string} 格式化后的日期时间字符串 */ formatDateTime(date) { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); const day = String(date.getDate()).padStart(2, "0"); const hours = String(date.getHours()).padStart(2, "0"); const minutes = String(date.getMinutes()).padStart(2, "0"); const seconds = String(date.getSeconds()).padStart(2, "0"); return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; }, /** * 获取数据目录 * @param {string} [subdir='statistical-ranking'] 子目录名称 * @returns {string} 数据目录的绝对路径 */ getDataDirectory(subdir = "statistical-ranking") { const dataDir = path.join(process.cwd(), "data", subdir); if (!fs.existsSync(dataDir)) { fs.mkdirSync(dataDir, { recursive: true }); } return dataDir; }, /** * 获取平台用户ID,尝试从绑定数据中查找 * @param {any} session - 会话对象 * @returns {Promise<string>} 平台用户ID */ async getPlatformId(session) { if (!session?.userId || !session?.platform || !session?.app?.database) return session?.userId || ""; try { const [binding] = await session.app.database.get("binding", { aid: session.userId, platform: session.platform }); return binding?.pid || session.userId; } catch { return session.userId || ""; } }, /** * 获取会话信息,包括平台、群组、用户等信息 * @param {any} session - 会话对象 * @returns {Promise<{platform: string, guildId: string, userId: string, userName: string, guildName: string} | null>} * 会话信息对象,获取失败时返回null */ async getSessionInfo(session) { if (!session) return null; const platform = session.platform; const guildId = session.guildId || session.groupId || session.channelId || "private"; const userId = await this.getPlatformId(session); const bot = session.bot; let userName = "", guildName = ""; userName = session.username ?? ""; if (!userName && bot?.getGuildMember) { const member = await bot.getGuildMember(guildId, userId).catch(() => null); userName = member?.username ?? ""; } if (guildId !== "private" && bot?.getGuild) { const guild = await bot.getGuild(guildId).catch(() => null); guildName = guild?.name ?? ""; } return { platform, guildId, userId, userName: this.sanitizeString(userName), guildName: this.sanitizeString(guildName) }; }, /** * 检查目标是否匹配规则列表中的任何规则 * @param {string[]} list - 规则列表 * @param {{platform: string, guildId: string, userId: string, command?: string}} target - 目标对象 * @returns {boolean} 是否匹配 */ matchRuleList(list, target) { return list.some((rule) => { if (target.command && rule === target.command) return true; const [rulePlatform = "", ruleGuild = "", ruleUser = ""] = rule.split(":"); return rulePlatform && target.platform === rulePlatform || ruleGuild && target.guildId === ruleGuild || ruleUser && target.userId === ruleUser || rule.endsWith(":") && target.platform && rule.startsWith(target.platform + ":"); }); }, /** * 构建条件描述 * @param {Object} options - 包含可能的条件的对象 * @returns {string[]} 条件描述数组 */ buildConditions(options) { return Object.entries({ user: ["用户", options.user], guild: ["群组", options.guild], platform: ["平台", options.platform], command: ["命令", options.command] }).filter(([_, [__, value]]) => value).map(([_, [label, value]]) => `${label}${value}`); }, /** * 标准化统计记录 * @param {any} record 待处理的记录 * @param {Object} options 选项 * @returns {any} 标准化后的记录 */ normalizeRecord(record, options = {}) { const result = { ...record }; if (options.sanitizeNames) { if (result.userName) { result.userName = this.sanitizeString(result.userName); } if (result.guildName) { result.guildName = this.sanitizeString(result.guildName); } } if (result.lastTime && !(result.lastTime instanceof Date)) { result.lastTime = new Date(result.lastTime); } if (result.count && typeof result.count !== "number") { result.count = parseInt(String(result.count)) || 1; } return result; }, /** * 生成统计数据映射表 * @param {Array<any>} records 记录数组 * @param {string} keyField 用作键的字段名 * @param {function} [keyFormatter] 键格式化函数 * @returns {Map<string, {count: number, lastTime: Date, displayName?: string}>} */ generateStatsMap(records, keyField, keyFormatter) { const dataMap = /* @__PURE__ */ new Map(); records.forEach((record) => { const recordKey = record[keyField]; if (!recordKey) return; const formattedKey = keyFormatter ? keyFormatter(recordKey) : recordKey; let displayName = formattedKey; if (keyField === "userId" && record.userName) { displayName = record.userName; } else if (keyField === "guildId" && record.guildName) { displayName = record.guildName; } const current = dataMap.get(formattedKey) || { count: 0, lastTime: record.lastTime, displayName }; current.count += record.count; if (record.lastTime > current.lastTime) { current.lastTime = record.lastTime; } dataMap.set(formattedKey, current); }); return dataMap; }, /** * 过滤统计记录 * @param {Array<any>} records 记录数组 * @param {Object} options 过滤选项 * @returns {Array<any>} 过滤后的记录 */ filterStatRecords(records, options = {}) { const { keyField = "command", displayWhitelist = [], displayBlacklist = [], disableCommandMerge = false } = options; let filteredRecords = records; if (keyField === "command" && !disableCommandMerge) { filteredRecords = records.filter((r) => r.command !== "_message"); } if (displayWhitelist.length || displayBlacklist.length) { filteredRecords = filteredRecords.filter((record) => { const key = record[keyField]; if (!key) return false; if (displayWhitelist.length) { return displayWhitelist.some((pattern) => key.includes(pattern)); } return !displayBlacklist.some((pattern) => key.includes(pattern)); }); } return filteredRecords; }, /** * 通用数据排序函数 * @param {Array<any>} data 数据数组 * @param {string} sortBy 排序字段: 'count' | 'key' | 'time' * @param {string} keyField 键字段名 * @returns {Array<any>} 排序后的数组 */ sortData(data, sortBy = "count", keyField = "key") { return [...data].sort((a, b) => { if (sortBy === "count") return b.count - a.count; if (sortBy === "time" && a.lastTime && b.lastTime) { return new Date(b.lastTime).getTime() - new Date(a.lastTime).getTime(); } return a[keyField].localeCompare(b[keyField]); }); }, /** * 处理名称显示 * @param {string} name 原始名称 * @param {string} id ID标识 * @param {boolean} truncateId 是否截断ID * @returns {string} 格式化后的名称 */ formatDisplayName(name2, id, truncateId = false) { if (!name2) return id || ""; const cleanName = this.sanitizeString(name2); if (!cleanName || /^[\s*□]+$/.test(cleanName)) return id || ""; if (truncateId || cleanName === id || cleanName.includes(id)) return cleanName; return `${cleanName} (${id})`; } }; // src/database.ts var database = { /** * 初始化数据库表结构 * @param ctx - Koishi 上下文 * @description 创建并定义 analytics.stat 表的结构 */ initialize(ctx) { ctx.model.extend("analytics.stat", { id: "unsigned", platform: { type: "string", length: 60 }, guildId: { type: "string", length: 150 }, userId: { type: "string", length: 150 }, command: { type: "string", length: 150 }, guildName: { type: "string", nullable: true }, userName: { type: "string", nullable: true }, count: "unsigned", lastTime: "timestamp" }, { primary: "id", autoInc: true, unique: [["platform", "guildId", "userId", "command"]] }); }, /** * 保存统计记录 * @param ctx - Koishi 上下文 * @param data - 需要保存的记录数据 * @description 更新或插入统计记录 */ async saveRecord(ctx, data) { data.command ||= "_message"; if (data.guildId?.includes("private")) return; try { const query = { platform: data.platform, guildId: data.guildId, userId: data.userId, command: data.command }; const normalizedData = Utils.normalizeRecord(data, { sanitizeNames: true }); const userName = normalizedData.userName; const guildName = normalizedData.guildName; const [existing] = await ctx.database.get("analytics.stat", query); if (existing) { const updateData = { count: existing.count + 1, lastTime: /* @__PURE__ */ new Date() }; if (userName !== void 0) updateData.userName = userName; if (guildName !== void 0) updateData.guildName = guildName; await ctx.database.set("analytics.stat", query, updateData); } else { await ctx.database.create("analytics.stat", { ...query, userName, guildName, count: 1, lastTime: /* @__PURE__ */ new Date() }); } } catch (e) { ctx.logger.error("保存记录失败:", e, data); } }, /** * 注册清除命令 * @param {Context} ctx Koishi 上下文 * @param {any} parent 父命令对象 */ registerClearCommand(ctx, parent) { parent.subcommand(".clear", "清除统计数据", { authority: 4 }).option("user", "-u [user:string] 指定用户").option("platform", "-p [platform:string] 指定平台").option("guild", "-g [guild:string] 指定群组").option("command", "-c [command:string] 指定命令").option("below", "-b [count:number] 少于指定次数", { fallback: 0 }).option("time", "-t [days:number] 指定天数之前", { fallback: 0 }).action(async ({ options }) => { const cleanOptions = { userId: options.user, platform: options.platform, guildId: options.guild, command: options.command }; const onlyBelowSpecified = options.below > 0 && !Object.values(cleanOptions).some(Boolean) && options.time <= 0; const onlyBeforeSpecified = options.time > 0 && !Object.values(cleanOptions).some(Boolean) && options.below <= 0; if (!options.below && !options.time && !Object.values(cleanOptions).some(Boolean)) { ctx.logger.info("正在删除所有统计记录并重建数据表..."); await ctx.database.drop("analytics.stat"); await this.initialize(ctx); ctx.logger.info("已删除所有统计记录"); return "已删除所有统计记录"; } const query = Object.fromEntries( Object.entries(cleanOptions).filter(([_, value]) => Boolean(value)) ); if (options.below > 0) { query.count = { $lt: options.below }; } if (options.time > 0) { const cutoffDate = /* @__PURE__ */ new Date(); cutoffDate.setDate(cutoffDate.getDate() - options.time); query.lastTime = { $lt: cutoffDate }; } const recordsToDelete = await ctx.database.get("analytics.stat", query, ["id"]); const deleteCount = recordsToDelete.length; await ctx.database.remove("analytics.stat", query); const conditions = Utils.buildConditions(options); let message = ""; const belowText = options.below > 0 ? `少于${options.below}次` : ""; const beforeText = options.time > 0 ? `${options.time}天前` : ""; const thresholdText = [belowText, beforeText].filter(Boolean).join("且"); if (onlyBelowSpecified) { message = `已删除所有少于${options.below}次的统计记录`; } else if (onlyBeforeSpecified) { message = `已删除所有${options.time}天前的统计记录`; } else if (conditions.length) { message = `已删除${conditions.join("、")}的统计记录`; if (thresholdText) { message += `中${thresholdText}的记录`; } } else { message = `已删除所有统计记录`; } message += `(共${deleteCount}条)`; return message; }); } }; // src/io.ts var fs2 = __toESM(require("fs")); var path2 = __toESM(require("path")); var io = { /** * 导出统计数据到文件 * @param {Context} ctx Koishi 上下文 * @param {string} filename 文件名(不含扩展名) * @param {Object} options 导出选项 * @param {string} [options.userId] 筛选特定用户ID * @param {string} [options.platform] 筛选特定平台 * @param {string} [options.guildId] 筛选特定群组ID * @param {string} [options.command] 筛选特定命令 * @param {number} [options.batchSize] 批处理大小,默认为200条/批 * @returns {Promise<{count: number, batches: number, files: Array<{count: number, path: string, filename: string, batch: number, totalBatches: number}>}>} 导出结果 * @throws {Error} 导出失败时抛出错误 */ async exportToFile(ctx, filename, options) { const query = Object.fromEntries( Object.entries({ ...options, batchSize: void 0 }).filter(([_, value]) => Boolean(value)) ); const records = await ctx.database.get("analytics.stat", query); if (!records.length) throw new Error("历史数据为空"); const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:T.]/g, "-").substring(0, 19); const batchSize = options.batchSize || 200; const totalRecords = records.length; const batches = Math.ceil(totalRecords / batchSize); const exportFiles = []; const statDir = Utils.getDataDirectory(); for (let batch = 0; batch < batches; batch++) { const start = batch * batchSize; const end = Math.min((batch + 1) * batchSize, totalRecords); const batchRecords = records.slice(start, end); const outputFilename = batches === 1 ? `${filename}-${timestamp}.json` : `${filename}-${timestamp}-${batches}-${batch + 1}.json`; const filePath = path2.join(statDir, outputFilename); fs2.writeFileSync( filePath, JSON.stringify(batchRecords.map(({ id, ...rest }) => rest), null, 2), "utf-8" ); exportFiles.push({ count: batchRecords.length, path: filePath, filename: outputFilename, batch: batch + 1, totalBatches: batches }); } return { count: totalRecords, batches, files: exportFiles }; }, /** * 列出可导入的统计数据文件 * @param {Context} ctx Koishi 上下文 * @returns {Promise<{files: string[], fileInfo: Record<string, any>}>} 文件列表和详细信息 */ async listImportFiles(ctx) { const statDir = Utils.getDataDirectory(); const files = await fs2.promises.readdir(statDir); const statFiles = files.filter( (file) => file.endsWith(".json") && (file.includes("stat") || file.includes("analytics")) ); if (!statFiles.length) return { files: [], fileInfo: {} }; const fileInfo = {}; const batchGroups = /* @__PURE__ */ new Map(); for (const file of statFiles) { const stats = await fs2.promises.stat(path2.join(statDir, file)); const batchMatch = file.match(/(.*)-(\d+)-(\d+)\.json$/); const isBatch = !!batchMatch; fileInfo[file] = { mtime: stats.mtime.toLocaleString(), timestamp: stats.mtime.getTime(), isBatch, batchInfo: isBatch ? { base: batchMatch[1], total: parseInt(batchMatch[2]), current: parseInt(batchMatch[3]) } : void 0 }; if (isBatch) { const [, base, total] = batchMatch; const key = `${base}-total${total}`; if (!batchGroups.has(key)) batchGroups.set(key, []); batchGroups.get(key).push(file); } } const batchGroupFiles = []; for (const [, files2] of batchGroups.entries()) { if (files2.length <= 1) continue; const firstFile = files2[0]; const groupInfo = firstFile.match(/(.*)-(\d+)-(\d+)\.json$/); if (!groupInfo) continue; const [, base, total] = groupInfo; const groupName = `${base}(N=${total})`; fileInfo[groupName] = { mtime: new Date(Math.max(...files2.map((f) => fileInfo[f].timestamp))).toLocaleString(), timestamp: Math.max(...files2.map((f) => fileInfo[f].timestamp)), isBatch: true, isGroup: true, batchInfo: { base, total: parseInt(total), files: files2.sort((a, b) => { const aMatch = a.match(/-(\d+)-(\d+)/); const bMatch = b.match(/-(\d+)-(\d+)/); if (aMatch && bMatch) { if (aMatch[1] === bMatch[1]) { return parseInt(aMatch[2]) - parseInt(bMatch[2]); } } return 0; }) } }; batchGroupFiles.push(groupName); } const sortedFiles = [...batchGroupFiles, ...statFiles].sort((a, b) => { const aInfo = fileInfo[a], bInfo = fileInfo[b]; return aInfo.isGroup !== bInfo.isGroup ? aInfo.isGroup ? -1 : 1 : bInfo.timestamp - aInfo.timestamp; }); return { files: sortedFiles, fileInfo }; }, /** * 从文件导入统计数据 * @param {Context} ctx Koishi 上下文 * @param {string} filename 文件名或文件组标识 * @param {boolean} [overwrite=false] 是否覆盖现有数据 * @returns {Promise<string>} 导入结果消息 * @throws {Error} 导入失败时抛出错误 */ async importFromFile(ctx, filename, overwrite = false) { const dataDir = Utils.getDataDirectory(); let files = []; if (/^\d+-\d+$/.test(filename)) { const [groupIdx, fileIdx] = filename.split("-").map(Number); const { files: filesList, fileInfo } = await this.listImportFiles(ctx); if (groupIdx < 1 || groupIdx > filesList.length || !filesList[groupIdx - 1].includes("(N=") || !fileInfo[filesList[groupIdx - 1]]?.batchInfo?.files || fileIdx < 1 || fileIdx > fileInfo[filesList[groupIdx - 1]]?.batchInfo?.files.length) { throw new Error(`文件序号无效`); } const groupName = filesList[groupIdx - 1]; const targetFile = fileInfo[groupName].batchInfo.files[fileIdx - 1]; const targetPath = path2.join(dataDir, targetFile); if (fs2.existsSync(targetPath)) { files.push(targetFile); } } else if (filename.includes("(N=")) { const match = filename.match(/(.*)\(N=(\d+)\)$/); const [, baseFilename, totalBatches] = match; for (let i = 1; i <= parseInt(totalBatches); i++) { const batchFile = `${baseFilename}-${totalBatches}-${i}.json`; const batchPath = path2.join(dataDir, batchFile); if (fs2.existsSync(batchPath)) { files.push(batchFile); } } } else { const fileToCheck = filename.endsWith(".json") ? filename : `${filename}.json`; const filePath = path2.join(dataDir, fileToCheck); if (fs2.existsSync(filePath)) { files.push(fileToCheck); } } if (overwrite) { await ctx.database.remove("analytics.stat", {}); } let totalStats = { imported: 0, errors: 0, invalidRecords: 0 }; for (let i = 0; i < files.length; i++) { const content = await fs2.promises.readFile(path2.join(dataDir, files[i]), "utf-8"); const { validRecords, invalidRecords } = this.parseJSON(content); const result = await this.importRecords(ctx, validRecords); totalStats.imported += result.imported; totalStats.errors += result.errors; totalStats.invalidRecords += invalidRecords; } const totalAttempted = totalStats.imported + totalStats.errors; return files.length === 1 ? `导入成功(${totalStats.imported}/${totalAttempted}条)` : `批量导入成功(${totalStats.imported}/${totalAttempted}条)`; }, /** * 从 analytics 插件导入历史数据 * @param {Context} ctx Koishi 上下文 * @param {boolean} [overwrite=false] 是否覆盖现有数据 * @returns {Promise<string>} 导入结果消息 * @throws {Error} 导入失败时抛出错误 */ async importLegacyData(ctx, overwrite = false) { if (!ctx.database.tables["analytics.command"]) { throw new Error("无历史数据表,请安装 analytics 插件"); } const [records, bindings] = await Promise.all([ ctx.database.get("analytics.command", {}), ctx.database.get("binding", {}) ]); if (!records.length) throw new Error("历史数据为空"); if (overwrite) { await ctx.database.remove("analytics.stat", {}); } const userIdMap = new Map( bindings.filter((b) => b.aid).map((b) => [b.aid.toString(), { pid: b.pid, platform: b.platform }]) ); const mergedRecords = /* @__PURE__ */ new Map(); records.forEach((cmd) => { const binding = userIdMap.get(cmd.userId?.toString()); if (!binding || !cmd.channelId) return; const commandValue = cmd.name || "_message"; const key = `${binding.platform}:${cmd.channelId}:${binding.pid}:${commandValue}`; const timestamp = new Date(cmd.date * 864e5 + (cmd.hour || 0) * 36e5); if (isNaN(timestamp.getTime())) return; const curr = mergedRecords.get(key) || { platform: binding.platform, guildId: cmd.channelId, userId: binding.pid, command: commandValue, count: 0, lastTime: timestamp, userName: "", guildName: "" }; curr.count += cmd.count || 1; curr.lastTime = new Date(Math.max(curr.lastTime.getTime(), timestamp.getTime())); mergedRecords.set(key, curr); }); const result = await this.importRecords(ctx, Array.from(mergedRecords.values())); const totalAttempted = result.imported + result.errors; return `导入成功(${result.imported}/${totalAttempted}条)`; }, /** * 解析JSON格式的统计数据 * @param {string} content JSON格式的字符串内容 * @returns {{validRecords: Array<StatRecord>, totalRecords: number, invalidRecords: number}} 解析结果,包括有效记录、总记录数和无效记录数 * @throws {Error} 解析失败时抛出错误 */ parseJSON(content) { try { const data = JSON.parse(content); let invalidRecords = 0; const validRecords = []; for (const record of data) { if (!record.platform || !record.guildId || !record.userId || !record.command) { invalidRecords++; continue; } const { id, ...rest } = record; validRecords.push(Utils.normalizeRecord({ ...rest, platform: rest.platform, guildId: rest.guildId, userId: rest.userId, userName: rest.userName ?? "", guildName: rest.guildName ?? "", command: rest.command, count: parseInt(String(rest.count)) || 1, lastTime: rest.lastTime ? new Date(rest.lastTime) : /* @__PURE__ */ new Date() }, { sanitizeNames: true })); } return { validRecords, totalRecords: data.length, invalidRecords }; } catch (error) { throw new Error(error.message); } }, /** * 将统计记录导入到数据库 * @param {Context} ctx Koishi 上下文 * @param {Array<StatRecord>} records 要导入的统计记录数组 * @returns {Promise<{imported: number, errors: number}>} 导入结果,包括成功导入数和错误数 */ async importRecords(ctx, records) { let imported = 0, errors = 0; const batchSize = 100; for (let i = 0; i < records.length; i += batchSize) { const batch = records.slice(i, i + batchSize); await Promise.all(batch.map(async (record) => { const query = { platform: record.platform, guildId: record.guildId, userId: record.userId, command: record.command }; try { const [existing] = await ctx.database.get("analytics.stat", query); if (existing) { const existingUserName = existing.userName?.trim() || ""; const recordUserName = Utils.sanitizeString(record.userName || ""); const newUserName = existingUserName && recordUserName ? record.lastTime > existing.lastTime ? recordUserName : existingUserName : existingUserName || recordUserName; const existingGuildName = existing.guildName?.trim() || ""; const recordGuildName = Utils.sanitizeString(record.guildName || ""); const newGuildName = existingGuildName && recordGuildName ? record.lastTime > existing.lastTime ? recordGuildName : existingGuildName : existingGuildName || recordGuildName; await ctx.database.set("analytics.stat", query, { count: existing.count + (record.count || 1), lastTime: record.lastTime > existing.lastTime ? record.lastTime : existing.lastTime, userName: newUserName, guildName: newGuildName }); } else { await ctx.database.create("analytics.stat", { ...query, count: record.count || 1, lastTime: record.lastTime || /* @__PURE__ */ new Date(), userName: Utils.sanitizeString(record.userName || ""), guildName: Utils.sanitizeString(record.guildName || "") }); } imported++; } catch (e) { errors++; } })); } return { imported, errors }; }, /** * 注册导入导出命令 * @param {Context} ctx Koishi 上下文 * @param {any} parent 父命令对象 */ registerCommands(ctx, parent) { parent.subcommand(".export", "导出统计数据", { authority: 4 }).option("user", "-u [user:string] 指定用户").option("platform", "-p [platform:string] 指定平台").option("guild", "-g [guild:string] 指定群组").option("command", "-c [command:string] 指定命令").action(async ({ options, session }) => { try { if (Object.values(options).some(Boolean)) { await session.send("正在导出..."); } const result = await this.exportToFile(ctx, "stat", { userId: options.user, platform: options.platform, guildId: options.guild, command: options.command }); if (result.batches === 1) { return `导出成功(${result.count}条): - ${result.files[0].filename}`; } else { const fileList = result.files.map((f) => `- ${f.filename}`).join("\n"); return `导出成功(${result.count}条): ${fileList}`; } } catch (e) { return `导出失败:${e.message}`; } }); parent.subcommand(".import [selector:number]", "导入统计数据", { authority: 4 }).option("force", "-f 覆盖现有数据").option("database", "-d 从历史数据库导入").action(async ({ session, options, args }) => { try { if (options.database) { session.send("正在导入历史记录..."); try { return await this.importLegacyData(ctx, options.force); } catch (e) { return e.message; } } const { files, fileInfo } = await this.listImportFiles(ctx); if (!files.length) { return "未找到历史记录文件"; } const selector = args[0]; if (selector) { if (selector > 0 && selector <= files.length) { const targetFile = files[selector - 1]; await session.send(`正在${options.force ? "覆盖" : ""}导入文件: - ${targetFile}`); return await this.importFromFile(ctx, targetFile, options.force); } return "请输入正确的序号"; } const fileList = files.map((file, index) => { const info = fileInfo[file] || {}; let prefix = "📄"; if (file.includes("(N=")) { prefix = "📦"; } else if (info.isBatch) { prefix = "📎"; } return `${index + 1}.${prefix}${file}`; }).join("\n"); return `使用 import [序号]导入对应文件: ${fileList}`; } catch (e) { return `导入失败:${e.message}`; } }); } }; // src/stat.ts var statProcessor = { /** * 处理统计记录并格式化显示 * @param {StatRecord[]} records - 统计记录数组 * @param {keyof StatRecord} aggregateKey - 聚合键 * @param {StatProcessOptions} [options={}] - 处理选项 * @returns {Promise<{items: string[], page: number, totalPages: number, totalItems: number, title: string}>} * 处理后的结果,包含格式化项目、分页信息和标题 */ async processStatRecords(records, aggregateKey, options = {}) { const { sortBy = "count", limit, disableCommandMerge = false, truncateId = false, displayBlacklist = [], displayWhitelist = [], page = 1, pageSize = 15, title = "", skipPaging = false } = options; const filteredRecords = Utils.filterStatRecords(records, { keyField: aggregateKey, displayWhitelist, displayBlacklist, disableCommandMerge }); const keyFormatter = aggregateKey === "command" && !disableCommandMerge ? (k) => k?.split(".")[0] || "" : void 0; const statsMap = Utils.generateStatsMap(filteredRecords, aggregateKey, keyFormatter); let entries = Array.from(statsMap.entries()).sort((a, b) => { if (sortBy === "count") return b[1].count - a[1].count; if (sortBy === "time") return new Date(b[1].lastTime).getTime() - new Date(a[1].lastTime).getTime(); return a[0].localeCompare(b[0]); }); const totalItems = entries.length; let pagedEntries = entries; let currentPage = 1, totalPages = 1; if (!skipPaging) { const effectiveLimit = limit ? Math.min(totalItems, limit) : totalItems; totalPages = Math.ceil(effectiveLimit / pageSize) || 1; currentPage = Math.min(Math.max(1, page), totalPages); const startIdx = (currentPage - 1) * pageSize; const endIdx = limit ? Math.min(startIdx + pageSize, limit, totalItems) : Math.min(startIdx + pageSize, totalItems); pagedEntries = entries.slice(startIdx, endIdx); } const formattedTitle = !skipPaging && totalPages > 1 ? `${title.endsWith(" ——") ? title.slice(0, -3) : title}(第${currentPage}/${totalPages}页)——` : title; const countWidth = 6, timeWidth = 10, nameWidth = 18; const items = pagedEntries.map(([key, { count, lastTime }]) => { const displayName = Utils.formatDisplayName( statsMap.get(key)?.displayName || key, key, truncateId ); const truncatedName = Utils.truncateByDisplayWidth(displayName, nameWidth); const countStr = count.toString() + (aggregateKey === "command" ? "次" : "条"); const truncatedCount = Utils.truncateByDisplayWidth(countStr, countWidth); const timeAgo = Utils.formatTimeAgo(lastTime); const truncatedTime = Utils.truncateByDisplayWidth(timeAgo, timeWidth); const namePadding = " ".repeat(Math.max(0, nameWidth - Utils.getStringDisplayWidth(truncatedName))); const countPadding = " ".repeat(Math.max(0, countWidth - Utils.getStringDisplayWidth(truncatedCount))); return `${truncatedName}${namePadding} ${countPadding}${truncatedCount} ${truncatedTime}`; }); return { items, page: currentPage, totalPages, totalItems, title: formattedTitle }; }, /** * 处理统计查询并返回结果 * @param {Context} ctx - Koishi上下文 * @param {QueryOptions} options - 查询选项 * @param {'command' | 'user' | 'guild'} type - 查询类型 * @returns {Promise<string | {records: StatRecord[], title: string}>} 查询结果或错误信息 */ async handleStatQuery(ctx, options, type) { const query = {}; const typeMap = { command: "命令", user: "发言", guild: "群组" }; if (options.user) query.userId = options.user; if (options.guild) query.guildId = options.guild; if (options.platform) query.platform = options.platform; if (type === "user") { query.command = "_message"; } else if (type === "command") { query.command = options.command || { $neq: "_message" }; } else if (options.command) { query.command = options.command; } const records = await ctx.database.get("analytics.stat", query); if (!records?.length) return "未找到记录"; let userName = "", guildName = ""; if (options.user) { const userRecord = records.find((r) => r.userId === options.user && r.userName); userName = userRecord?.userName || ""; } if (options.guild) { const guildRecord = records.find((r) => r.guildId === options.guild && r.guildName); guildName = guildRecord?.guildName || ""; } const conditions = Utils.buildConditions({ user: options.user ? userName || options.user : null, guild: options.guild ? guildName || options.guild : null, platform: options.platform, command: options.command }); let title = ""; if (conditions.length) { title = `${conditions.join("、")}的${typeMap[type]}统计 ——`; } else if (options.guild && type !== "guild") { const guildDisplay = guildName || options.guild; title = `${guildDisplay}的${typeMap[type]}统计 ——`; } else { title = `全局${typeMap[type]}统计 ——`; } return { records, title }; }, /** * 格式化并返回指定类型的列表 * @param {StatRecord[]} records - 统计记录数组 * @param {keyof StatRecord} key - 要获取的键名 * @param {string} title - 列表标题 * @returns {string|null} 格式化后的列表字符串,无内容则返回null */ formatList: /* @__PURE__ */ __name((records, key, title) => { const uniqueKeys = [...new Set(records.map((r) => r[key]).filter(Boolean))]; if (key === "command") { const commands = uniqueKeys.filter((cmd) => cmd !== "_message"); return commands.length ? `${title} —— ${commands.join(",")}` : null; } else if (key === "userId" || key === "guildId") { const items = uniqueKeys.map((id) => { const record = records.find((r) => r[key] === id); const name2 = key === "userId" ? record?.userName : record?.guildName; return name2 ? `${name2} (${id})` : id; }); return items.length ? `${title} —— ${items.join(",")}` : null; } return uniqueKeys.length ? `${title} —— ${uniqueKeys.join(",")}` : null; }, "formatList"), /** * 注册列表查看子命令 * @param {Context} ctx - Koishi上下文 * @param {any} parent - 父命令对象 */ registerListCommand(ctx, parent) { parent.subcommand(".list", "查看类型列表", { authority: 3 }).option("user", "-u 显示用户列表").option("guild", "-g 显示群组列表").action(async ({ options }) => { const records = await ctx.database.get("analytics.stat", {}); if (!records?.length) return "未找到记录"; const hasParams = options.user || options.guild; const parts = []; if (!hasParams) { parts.push(this.formatList(records, "platform", "平台列表")); parts.push(this.formatList(records, "command", "命令列表")); } if (options.user) parts.push(this.formatList(records, "userId", "用户列表")); if (options.guild) parts.push(this.formatList(records, "guildId", "群组列表")); return parts.filter(Boolean).join("\n"); }); } }; // src/render.ts var Renderer = class { static { __name(this, "Renderer"); } ctx; constructor(ctx) { this.ctx = ctx; } /** * 将HTML内容转换为图片 * @param {string} html - 要渲染的HTML内容 * @param {Object} options - 渲染选项 * @param {number} [options.width] - 图片宽度 * @returns {Promise<Buffer>} 图片Buffer数据 */ async htmlToImage(html, options = {}) { let page = null; try { page = await this.ctx.puppeteer.page(); const initialViewportWidth = options.width || 720; await page.setViewport({ width: initialViewportWidth, height: 1080, deviceScaleFactor: 2 }); await page.setDefaultNavigationTimeout(3e4); await page.setDefaultTimeout(3e4); await page.setContent(` <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <style> body { margin: 0; padding: 0; font-family: "Microsoft YaHei", "PingFang SC", sans-serif; background: transparent; color: rgba(0, 0, 0, 0.87); font-size: 14px; line-height: 1.4; -webkit-font-smoothing: antialiased; } table { width: 100%; table-layout: auto; border-collapse: separate; border-spacing: 0; overflow: hidden; } h2, h3 { margin: 0; letter-spacing: 0.5px; font-weight: 500; } .material-card { border-radius: 10px; overflow: hidden; background-color: #fff; box-shadow: 0 2px 4px -1px rgba(0,0,0,0.2), 0 4px 5px 0 rgba(0,0,0,0.14), 0 1px 10px 0 rgba(0,0,0,0.12); margin: 4px; padding: 12px; } .stat-chip { padding: 0 10px; height: 28px; display: inline-flex; align-items: center; border-radius: 14px; font-size: 14px; line-height: 28px; background-color: rgba(0, 0, 0, 0.06); color: rgba(0, 0, 0, 0.87); white-space: nowrap; } .stat-table th { font-weight: 500; color: white; padding: 8px 12px; position: sticky; top: 0; box-shadow: 0 1px 2px rgba(0,0,0,0.1); } .stat-table td { padding: 6px 12px; border-bottom: 1px solid rgba(0, 0, 0, 0.04); position: relative; } .highlight-row td { background-color: rgba(33, 150, 243, 0.03); font-weight: 500; } .table-container { border-radius: 8px; overflow: hidden; border: 1px solid rgba(0, 0, 0, 0.06); } </style> </head> <body>${html}</body> </html> `, { waitUntil: "networkidle0" }); const dimensions = await page.evaluate(() => { const contentWidth = Math.max( document.body.scrollWidth, document.body.offsetWidth, document.documentElement.clientWidth, document.documentElement.scrollWidth, document.documentElement.offsetWidth ); const contentHeight = document.body.scrollHeight; return { width: contentWidth, height: contentHeight }; }); await page.setViewport({ width: dimensions.width, height: dimensions.height, deviceScaleFactor: 2 }); await page.evaluate(() => { const imgPromises = Array.from(document.querySelectorAll("img")).map( (img) => img.complete ? Promise.resolve() : new Promise((resolve) => { img.addEventListener("load", resolve); img.addEventListener("error", resolve); }) ); return Promise.all(imgPromises); }); return await page.screenshot({ type: "png", fullPage: true, omitBackground: true }); } catch (error) { this.ctx.logger.error("图片渲染出错:", error); throw new Error(`图片渲染出错: ${error.message || "未知错误"}`); } finally { if (page) { await page.close().catch(() => { }); } } } /** * 将统计记录转换为图表数据 * @param {StatRecord[]} records - 统计记录数组 * @param {keyof StatRecord} key - 统计键名 * @param {StatProcessOptions} options - 处理选项 * @returns {Array<{name: string, value: number, time: string, rawTime: Date}>} 转换后的图表数据 */ recordsToChartData(records, key, options = {}) { const { sortBy = "count", disableCommandMerge = false, truncateId = false, displayBlacklist = [], displayWhitelist = [] } = options; const filteredRecords = Utils.filterStatRecords(records, { keyField: key, displayWhitelist, displayBlacklist, disableCommandMerge }); const keyFormatter = key === "command" && !disableCommandMerge ? (k) => k?.split(".")[0] || "" : void 0; const dataMap = Utils.generateStatsMap(filteredRecords, key, keyFormatter); let chartData = Array.from(dataMap.entries()).map(([key2, data]) => ({ name: Utils.formatDisplayName(data.displayName, key2, truncateId), value: data.count, time: Utils.formatTimeAgo(data.lastTime), rawTime: data.lastTime })); chartData.sort((a, b) => { if (sortBy === "count") return b.value - a.value; if (sortBy === "time") return b.rawTime.getTime() - a.rawTime.getTime(); return a.name.localeCompare(b.name); }); return chartData; } /** * 将统计记录分页处理 * @param {Array<{name: string, value: number, time: string, rawTime: Date}>} data - 统计数据 * @param {number} maxRowsPerPage - 每页最大行数 * @param {number} minRowsForNewPage - 创建新页面的最小行数 * @returns {Array<Array<{name: string, value: number, time: string, rawTime: Date}>>} 分页后的数据 */ paginateData(data, maxRowsPerPage = 200, minRowsForNewPage = 50) { if (!data.length || data.length <= maxRowsPerPage) return [data]; const totalRows = data.length; const normalPageCount = Math.ceil(totalRows / maxRowsPerPage); const lastPageRows = totalRows - (normalPageCount - 1) * maxRowsPerPage; const actualPageCount = lastPageRows < minRowsForNewPage && normalPageCount > 1 ? normalPageCount - 1 : normalPageCount; if (actualPageCount <= 1) return [data]; const mainPageSize = Math.ceil(totalRows / actualPageCount); const pages = []; let currentIdx = 0; for (let i = 0; i < actualPageCount; i++) { const pageSize = i === actualPageCount - 1 ? totalRows - currentIdx : mainPageSize; pages.push(data.slice(currentIdx, currentIdx + pageSize)); currentIdx += pageSize; } return pages; } /** * 生成统计数据的图片 * @param {StatRecord[]} records - 统计记录数组 * @param {keyof StatRecord} key - 统计键名 * @param {string} title - 图表标题 * @param {StatProcessOptions} options - 处理选项 * @returns {Promise<Buffer[]>} 生成的图片Buffer数组 */ async generateStatImage(records, key, title, options = {}) { const chartData = this.recordsToChartData(records, key, options); const headerColor = key === "userId" ? "#9C27B0" : key === "guildId" ? "#4CAF50" : "#2196F3"; const pages = this.paginateData(chartData); const results = []; const currentTime = Utils.formatDateTime(/* @__PURE__ */ new Date()); const totalItems = chartData.length; const totalCount = chartData.reduce((sum, item) => sum + item.value, 0); for (let i = 0; i < pages.length; i++) { const pageData = pages[i]; const pageTitle = pages.length > 1 ? `${title} (${i + 1}/${pages.length})` : title; const html = ` <div class="material-card"> <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:12px; padding-bottom:10px; border-bottom:1px solid rgba(0,0,0,0.08); flex-wrap:nowrap;"> <div style="display:flex; gap:8px; flex-shrink:0; margin-right:12px;"> <div class="stat-chip"> <span style="color:rgba(0,0,0,0.6);">总项目: </span> <span style="font-weight:500; margin-left:3px;">${totalItems}</span> </div> <div class="stat-chip"> <span style="color:rgba(0,0,0,0.6);">总${key === "command" ? "次数" : "条数"}: </span> <span style="font-weight:500; margin-left:3px;">${totalCount}</span> </div> </div> <h2 style="margin:0; font-size:18px; text-align:center; flex-grow:1; font-weight:500;">${pageTitle}</h2> <div class="stat-chip" style="color:rgba(0,0,0,0.6); margin-left:12px;">${currentTime}</div> </div> ${this.generateTableHTML(pageData, key, headerColor)} </div> `; results.push(await this.htmlToImage(html)); } return results; } /** * 生成综合统计图,将用户的所有统计信息整合到一张图中 * @param {Array<{records: StatRecord[], title: string, key: keyof StatRecord, options?: StatProcessOptions}>} datasets - 多个数据集 * @param {string} mainTitle - 主标题 * @returns {Promise<Buffer[]>} 生成的图片Buffer数组 */ async generateCombinedStatImage(datasets, mainTitle) { const processedDatasets = datasets.map((dataset) => { if (!dataset.records.length) return { chartData: [], key: dataset.key, title: dataset.title, headerColor: "", totalItems: 0, totalCount: 0 }; const chartData = this.recordsToChartData(dataset.records, dataset.key, dataset.options); const headerColor = dataset.key === "userId" ? "#9C27B0" : dataset.key === "guildId" ? "#4CAF50" : "#2196F3"; const totalItems = chartData.length; const totalCount = chartData.reduce((sum, item) => sum + item.value, 0); return { chartData, key: dataset.key, title: dataset.title, headerColor, totalItems, totalCount }; }).filter((d) => d.chartData.length > 0); if (processedDatasets.length === 0) return [await this.htmlToImage(`<div style="padding:24px; text-align:center;">没有数据</div>`)]; let totalRows = processedDatasets.reduce((sum, dataset) => sum + dataset.chartData.length, 0); const currentTime = Utils.formatDateTime(/* @__PURE__ */ new Date()); if (totalRows <= 200) { const tablesHTML = processedDatasets.map((dataset, index) => { const isLastDataset = index === processedDatasets.length - 1; return ` <div style="margin-bottom:${isLastDataset ? "0" : "16px"};"> <div style="display:flex; align-items:center; margin:8px 0; flex-wrap:nowrap;"> <div style="display:flex; gap:8px; flex-shrink:0; margin-right:12px;"> <div class="stat-chip"> <span style="color:rgba(0,0,0,0.6);">总项目: </span> <span style="font-weight:500; margin-left:3px;">${dataset.totalItems}</span> </div> <div class="stat-chip"> <span style="color:rgba(0,0,0,0.6);">${dataset.key === "command" ? "次数" : "条数"}: </span> <span style="font-weight:500; margin-left:3px;">${dataset.totalCount}</span> </div> </div> <h3 style="margin:0; font-size:16px; text-align:center; flex-grow:1; font-weight:500;">${dataset.title}</h3> <div style="flex-shrink:0; margin-left:10px; width:1px;"></div> </div> ${this.generateTableHTML(dataset.chartData, dataset.key, dataset.headerColor)}