diff --git a/package.json b/package.json index 1f65c42b..dff5fb35 100644 --- a/package.json +++ b/package.json @@ -36,12 +36,14 @@ "@types/qrcode-terminal": "^0.12.2", "@types/react-color": "^3.0.13", "@types/type-is": "^1.6.7", + "@types/wordcloud": "^1.2.2", "@types/ws": "^8.5.12", "@typescript-eslint/eslint-plugin": "^8.3.0", "@typescript-eslint/parser": "^8.3.0", "ajv": "^8.13.0", "async-mutex": "^0.5.0", "commander": "^13.0.0", + "compressing": "^1.10.1", "cors": "^2.8.5", "esbuild": "0.25.0", "eslint": "^9.14.0", @@ -54,21 +56,23 @@ "image-size": "^1.1.1", "json5": "^2.2.3", "multer": "^1.4.5-lts.1", + "napcat.protobuf": "^1.1.3", "typescript": "^5.3.3", "typescript-eslint": "^8.13.0", "vite": "^6.0.1", "vite-plugin-cp": "^4.0.8", "vite-tsconfig-paths": "^5.1.0", - "napcat.protobuf": "^1.1.3", - "winston": "^3.17.0", - "compressing": "^1.10.1" + "winston": "^3.17.0" }, "dependencies": { "@ffmpeg.wasm/core-mt": "^0.13.2", "@napi-rs/canvas": "^0.1.67", + "@node-rs/jieba": "^2.0.1", + "canvas": "^3.1.0", "express": "^5.0.0", "napcat.protobuf": "^1.1.2", "silk-wasm": "^3.6.1", + "wordcloud": "^1.2.3", "ws": "^8.18.0" } } diff --git a/src/core/apis/msg.ts b/src/core/apis/msg.ts index f0076ce0..a8f8c294 100644 --- a/src/core/apis/msg.ts +++ b/src/core/apis/msg.ts @@ -166,7 +166,18 @@ export class NTQQMsgApi { pageLimit: 20000, }); } - + async queryFirstMsgByTime(peer: Peer, filterMsgFromTime: string, filterMsgToTime: string) { + return await this.context.session.getMsgService().queryMsgsWithFilterEx('0', '0', '0', { + chatInfo: peer, + filterMsgType: [], + filterSendersUid: [], + filterMsgToTime: filterMsgToTime, + filterMsgFromTime: filterMsgFromTime, + isReverseOrder: true, + isIncludeCurrent: true, + pageLimit: 20000, + }); + } async setMsgRead(peer: Peer) { return this.context.session.getMsgService().setMsgRead(peer); } diff --git a/src/onebot/types/message.ts b/src/onebot/types/message.ts index 756d5b5f..d56117ff 100644 --- a/src/onebot/types/message.ts +++ b/src/onebot/types/message.ts @@ -180,7 +180,7 @@ export interface OB11MessageNode { id?: string; user_id?: number | string; // number uin?: number | string; // number, compatible with go-cqhttp - nickname: string; + nickname?: string; name?: string; // compatible with go-cqhttp content: OB11MessageMixType; source?: string; diff --git a/src/plugin/index.ts b/src/plugin/index.ts index 02ffb4ab..a9a68f03 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -1,19 +1,46 @@ -import { NapCatOneBot11Adapter, OB11Message, OB11MessageDataType } from '@/onebot'; -import { ChatType, NapCatCore } from '@/core'; +import { NapCatOneBot11Adapter, OB11Message, OB11MessageData, OB11MessageDataType, OB11MessageNode } from '@/onebot'; +import { ChatType, NapCatCore, NTMsgAtType } from '@/core'; import { ActionMap } from '@/onebot/action'; import { OB11PluginAdapter } from '@/onebot/network/plugin'; import { MsgData } from '@/core/packet/client/nativeClient'; import { ProtoBufDecode } from 'napcat.protobuf'; -import { drawJsonContent } from '@/shell/napcat'; import appidList from "@/core/external/appid.json"; import { MessageUnique } from '@/common/message-unique'; +import { Jieba } from '@node-rs/jieba'; +import { dict } from '@node-rs/jieba/dict.js'; +import { generateWordCloud } from './wordcloud'; +import { drawJsonContent } from '@/shell/drawJson'; +const jieba = Jieba.withDict(dict); function timestampToDateText(timestamp: string): string { const date = new Date(+(timestamp + '000')); return date.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' }); } export const plugin_onmessage = async (adapter: string, _core: NapCatCore, _obCtx: NapCatOneBot11Adapter, message: OB11Message, action: ActionMap, instance: OB11PluginAdapter) => { if (typeof message.message === 'string' || !message.raw) return; - if (message.message.find(e => e.type == 'text' && e.data.text == '#取')) { + if (message.message.find(e => e.type == 'text' && e.data.text == '#千千的菜单')) { + let innermsg = + '#取 <@reply> 取回数据\n' + + '#取Onebot <@reply> 取回Onebot数据\n' + + '#取消息段 <@reply> 取回Onebot数据\n' + + '#谁说过 <关键词> 随机返回说过这个关键词的人的消息\n' + + '#谁经常说 <关键词> 随机返回说过这个关键词的人\n' + + '#Ta经常说什么 <@reply> 返回这个人说过的关键词\n' + + '#Ta经常什么时候聊天 <@reply> 返回这个人聊天的时间段\n' + + '#Ta经常和谁一起聊天 <@reply> 返回这个人聊天的对象\n' + + '#群友今日最爱表情包 <@reply> 返回这今天表情包\n' + + '#群友本周最爱表情包 <@reply> 返回这本周表情包\n' + + '#Ta最爱的表情包 <@reply> 返回这个人最爱的表情包' + await action.get('send_group_msg')?.handle({ + group_id: String(message.group_id), + message: [{ + type: OB11MessageDataType.image, + data: { + file: await drawJsonContent(innermsg) + } + }] + }, adapter, instance.config); + } + else if (message.message.find(e => e.type == 'text' && e.data.text == '#取')) { let reply = message.raw.elements.find(e => e.replyElement)?.replyElement?.replayMsgSeq; if (!reply) return; @@ -45,7 +72,6 @@ export const plugin_onmessage = async (adapter: string, _core: NapCatCore, _obCt } let now_appid = decodedData['1']['1']['4']; - console.log(now_appid); let versionList = Object.entries(appidList).filter(([_, appidData]) => appidData.appid == now_appid).map(([version, appidData]) => ({ version, appidData })); if (versionList.length > 0) { @@ -77,7 +103,30 @@ export const plugin_onmessage = async (adapter: string, _core: NapCatCore, _obCt message: msgList as any }, adapter, instance.config); } - if (message.message.find(e => e.type == 'text' && e.data.text.startsWith('#谁说过'))) { + else if (message.message.find(e => e.type == 'text' && (e.data.text == '#取Onebot' || e.data.text == '#取消息段'))) { + let reply_msg = message.message.find(e => e.type == 'reply')?.data.id; + if (!reply_msg) return; + let msg = await action.get('get_msg')?.handle({ message_id: reply_msg }, adapter, instance.config); + if (!msg) return; + let msgcontent = msg.data?.message; + await action.get('send_group_msg')?.handle({ + group_id: String(message.group_id), + message: [{ + type: OB11MessageDataType.node, + data: { + content: [ + { + type: OB11MessageDataType.text, + data: { + text: JSON.stringify(msgcontent, (_key, value) => typeof value === 'bigint' ? value.toString() : value, 2) + } + } + ] as OB11MessageData[] + } + }] + }, adapter, instance.config); + } + else if (message.message.find(e => e.type == 'text' && e.data.text.startsWith('#谁说过'))) { let text = message.message.find(e => e.type == 'text')?.data.text; if (!text) return; let keyWords = text.slice(4); @@ -102,7 +151,7 @@ export const plugin_onmessage = async (adapter: string, _core: NapCatCore, _obCt let onebotmsgid = MessageUnique.createUniqueMsgId({ chatType: ChatType.KCHATTYPEGROUP, peerUid: message.group_id?.toString() ?? "" }, msg?.msgId ?? ''); let msgJson = '关键词是:' + keyWords + '\n'; for (const msgitem of msgItems) { - msgJson += msgitem.senderNick + ' 在 ' + timestampToDateText(msgitem.msgTime) + ' 也说过哦' + '\n'; + msgJson += msgitem.senderNick + ' 在 ' + timestampToDateText(msgitem.msgTime) + ' 说 ' + msgitem.fieldText + '\n'; } msgJson = msgJson.slice(0, -1); await action.get('send_group_msg')?.handle({ @@ -127,7 +176,7 @@ export const plugin_onmessage = async (adapter: string, _core: NapCatCore, _obCt }, adapter, instance.config); } } - if (message.message.find(e => e.type == 'text' && e.data.text.startsWith('#谁经常说'))) { + else if (message.message.find(e => e.type == 'text' && e.data.text.startsWith('#谁经常说'))) { let text = message.message.find(e => e.type == 'text')?.data.text; if (!text) return; let keyWords = text.slice(5); @@ -179,4 +228,553 @@ export const plugin_onmessage = async (adapter: string, _core: NapCatCore, _obCt }, adapter, instance.config); } } + else if (message.message.find(e => e.type == 'text' && e.data.text.startsWith('#Ta经常说什么'))) { + let text_msg = message.message.find(e => e.type == 'text')?.data.text; + let at_msg = message.message.find(e => e.type == 'at')?.data.qq; + if (!at_msg) { + at_msg = message.user_id.toString(); + } + if (!text_msg || !at_msg) return; + let peer = { peerUid: message.group_id?.toString() ?? "", chatType: ChatType.KCHATTYPEGROUP }; + let sender_uid = await _core.apis.UserApi.getUidByUinV2(at_msg); + let msgs = (await _core.apis.MsgApi.queryFirstMsgBySender(peer, [sender_uid])).msgList; + let text_msg_list = msgs.map(e => e.elements.filter(e => e.textElement)).flat().map(e => e.textElement!.content); + let cutMap = new Map(); + for (const text_msg_list_item of text_msg_list) { + let msg = jieba.cut(text_msg_list_item, false); + for (const msg_item of msg) { + if (msg_item.length > 1) { + cutMap.set(msg_item, (cutMap.get(msg_item) ?? 0) + 1); + } + } + } + let rank = Array.from(cutMap.entries()).sort((a, b) => b[1] - a[1]).slice(0, 100); + let info = await _core.apis.GroupApi.getGroupMember(message.group_id?.toString() ?? "", at_msg.toString()) + let msgJson = info?.nick + ' 的历史发言词分析\n'; + for (const rankItem of rank) { + msgJson += rankItem[0] + ' 提到 ' + rankItem[1] + ' 次\n'; + } + msgJson = msgJson.slice(0, -1); + await action.get('send_group_msg')?.handle({ + group_id: String(message.group_id), + message: [{ + type: OB11MessageDataType.at, + data: { + qq: at_msg, + } + }, { + type: OB11MessageDataType.image, + data: { + file: await generateWordCloud(rank.map(e => ({ word: e[0], frequency: e[1] }))) + } + }] + }, adapter, instance.config); + } + else if (message.message.find(e => e.type == 'text' && e.data.text.startsWith('#Ta经常什么时候聊天'))) { + let text_msg = message.message.find(e => e.type == 'text')?.data.text; + let at_msg = message.message.find(e => e.type == 'at')?.data.qq; + if (!at_msg) { + at_msg = message.user_id.toString(); + } + if (!text_msg || !at_msg) return; + let peer = { peerUid: message.group_id?.toString() ?? "", chatType: ChatType.KCHATTYPEGROUP }; + let sender_uid = await _core.apis.UserApi.getUidByUinV2(at_msg); + let msgs = (await _core.apis.MsgApi.queryFirstMsgBySender(peer, [sender_uid])).msgList; + + // 统计每个时间段的消息数量 + const weekdayCount = new Map(); // 0-6 对应周日到周六 + const hourCount = new Map(); // 0-23 小时 + const timeSlotCount = new Map(); // 早上/下午/晚上等时段 + + // 定义时间段 + const timeSlots = [ + { name: "凌晨(0-6点)", start: 0, end: 6 }, + { name: "早上(6-10点)", start: 6, end: 10 }, + { name: "中午(10-14点)", start: 10, end: 14 }, + { name: "下午(14-18点)", start: 14, end: 18 }, + { name: "晚上(18-22点)", start: 18, end: 22 }, + { name: "深夜(22-24点)", start: 22, end: 24 } + ]; + + // 星期几的名称 + const weekdayNames = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"]; + + // 统计消息时间分布 + for (const msg of msgs) { + if (!msg.msgTime) continue; + + // 将消息时间转换为日期对象 + const timestamp = parseInt(msg.msgTime) * 1000; + const date = new Date(timestamp); + + // 获取星期几 (0-6, 0代表周日) + const weekday = date.getDay(); + weekdayCount.set(weekday, (weekdayCount.get(weekday) || 0) + 1); + + // 获取小时 (0-23) + const hour = date.getHours(); + hourCount.set(hour, (hourCount.get(hour) || 0) + 1); + + // 判断属于哪个时间段 + for (const slot of timeSlots) { + if (hour >= slot.start && hour < slot.end) { + timeSlotCount.set(slot.name, (timeSlotCount.get(slot.name) || 0) + 1); + break; + } + } + } + + // 准备结果文本 + let info = await _core.apis.GroupApi.getGroupMember(message.group_id?.toString() ?? "", at_msg.toString()); + let msgJson = `${info?.nick || at_msg} 的聊天时间分析\n`; + msgJson += `总计分析消息: ${msgs.length}条\n\n`; + + // 添加星期几统计 + msgJson += "按星期统计:\n"; + const totalWeekday = Array.from(weekdayCount.values()).reduce((a, b) => a + b, 0); + for (let i = 0; i < 7; i++) { + const count = weekdayCount.get(i) || 0; + const percentage = totalWeekday > 0 ? ((count / totalWeekday) * 100).toFixed(2) : "0.00"; + msgJson += `${weekdayNames[i]}: ${count}条 (${percentage}%)\n`; + } + + // 添加时间段统计 + msgJson += "\n按时间段统计:\n"; + const totalTimeSlot = Array.from(timeSlotCount.values()).reduce((a, b) => a + b, 0); + for (const slot of timeSlots) { + const count = timeSlotCount.get(slot.name) || 0; + const percentage = totalTimeSlot > 0 ? ((count / totalTimeSlot) * 100).toFixed(2) : "0.00"; + msgJson += `${slot.name}: ${count}条 (${percentage}%)\n`; + } + + // 发送结果 + await action.get('send_group_msg')?.handle({ + group_id: String(message.group_id), + message: [{ + type: OB11MessageDataType.at, + data: { + qq: at_msg, + } + }, { + type: OB11MessageDataType.text, + data: { + text: " 的聊天时间分析" + } + }, { + type: OB11MessageDataType.image, + data: { + file: await drawJsonContent(msgJson) + } + }] + }, adapter, instance.config); + } + else if (message.message.find(e => e.type == 'text' && e.data.text.startsWith('#Ta经常和谁一起聊天'))) { + let text_msg = message.message.find(e => e.type == 'text')?.data.text; + let at_msg = message.message.find(e => e.type == 'at')?.data.qq; + if (!at_msg) { + at_msg = message.user_id.toString(); + } + if (!text_msg || !at_msg) return; + let peer = { peerUid: message.group_id?.toString() ?? "", chatType: ChatType.KCHATTYPEGROUP }; + let sender_uid = await _core.apis.UserApi.getUidByUinV2(at_msg); + let msgs = (await _core.apis.MsgApi.queryFirstMsgBySender(peer, [sender_uid])).msgList; + let uinCount = new Map(); + + // 收集所有直接可用的 UIN + for (const msg of msgs) { + for (const elem of msg.elements) { + // 处理回复消息 + if (elem.replyElement) { + if (elem.replyElement.senderUin) { + uinCount.set(elem.replyElement.senderUin, (uinCount.get(elem.replyElement.senderUin) ?? 0) + 1); + } + } + + // 先处理那些不需要异步获取的 UIN + if (elem.textElement && elem.textElement?.atType == NTMsgAtType.ATTYPEONE) { + if (elem.textElement.atUid && elem.textElement.atUid !== '0') { + uinCount.set(elem.textElement.atUid, (uinCount.get(elem.textElement.atUid) ?? 0) + 1); + } + } + } + } + + // 收集所有需要异步获取的 UIN 查询结果 + const uidQueries: Promise<{ uin: string | null, count: number }>[] = []; + + for (const msg of msgs) { + // 处理需要异步解析的记录 + for (const record of msg.records) { + if (record.senderUin) { + uinCount.set(record.senderUin, (uinCount.get(record.senderUin) ?? 0) + 1); + } else if (record.senderUid) { + uidQueries.push((async () => { + const qq = await _core.apis.UserApi.getUinByUidV2(record.senderUid); + return { uin: qq, count: 1 }; + })()); + } + } + + // 处理需要异步解析的 @ 消息 + for (const elem of msg.elements) { + if (elem.textElement && elem.textElement?.atType == NTMsgAtType.ATTYPEONE) { + const { atNtUid, atUid } = elem.textElement; + if (atNtUid && (!atUid || atUid === '0')) { + uidQueries.push((async () => { + const qq = await _core.apis.UserApi.getUinByUidV2(atNtUid); + return { uin: qq, count: 1 }; + })()); + } + } + } + } + + // 等待所有异步查询完成并处理结果 + const results = await Promise.all(uidQueries); + + // 在所有异步操作完成后统一更新计数 + for (const result of results) { + if (result.uin) { + uinCount.set(result.uin, (uinCount.get(result.uin) ?? 0) + result.count); + } + } + + const rank = Array.from(uinCount.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 10); // 只取前10名 + + // 获取目标用户信息 + let info = await _core.apis.GroupApi.getGroupMember(message.group_id?.toString() ?? "", at_msg.toString()); + let msgJson = `${info?.nick || at_msg} 的互动用户分析\n`; + msgJson += `总计分析消息: ${msgs.length}条\n\n`; + + // 获取互动用户的昵称并生成结果 + if (rank.length > 0) { + msgJson += "最常互动的用户:\n"; + + // 收集所有获取昵称的异步操作 + const nicknamePromises = rank.map(async ([uin, count]) => { + try { + const memberInfo = await _core.apis.GroupApi.getGroupMember(message.group_id?.toString() ?? "", uin); + return { uin, count, nickname: memberInfo?.nick || uin }; + } catch (e) { + return { uin, count, nickname: uin }; + } + }); + + // 等待所有昵称获取完成 + const nicknames = await Promise.all(nicknamePromises); + + // 生成最终消息 + for (const { nickname, count } of nicknames) { + msgJson += `${nickname}: ${count}次互动\n`; + } + } else { + msgJson += "未找到互动记录\n"; + } + + msgJson = msgJson.slice(0, -1); + // 发送结果 + await action.get('send_group_msg')?.handle({ + group_id: String(message.group_id), + message: [{ + type: OB11MessageDataType.at, + data: { + qq: at_msg, + } + }, { + type: OB11MessageDataType.text, + data: { + text: " 的互动用户分析" + } + }, { + type: OB11MessageDataType.image, + data: { + file: await drawJsonContent(msgJson) + } + }] + }, adapter, instance.config); + } + else if (message.message.find(e => e.type == 'text' && (e.data.text.startsWith('#群友今日最爱表情包') || e.data.text.startsWith('#群友本周最爱表情包')))) { + let time = 0; + if (message.message.find(e => e.type == 'text' && e.data.text.startsWith('#群友本周最爱表情包'))) { + time = 7 * 24 * 60 * 60; // 一周的秒数 + } else if (message.message.find(e => e.type == 'text' && e.data.text.startsWith('#群友今日最爱表情包'))) { + time = 24 * 60 * 60; // 一天的秒数 + } + let timebefore = (Math.floor(Date.now() / 1000) - time).toString(); + let timeafter = Math.floor(Date.now() / 1000).toString(); + let peer = { peerUid: message.group_id?.toString() ?? "", chatType: ChatType.KCHATTYPEGROUP }; + let msgList = (await _core.apis.MsgApi.queryFirstMsgByTime(peer, timebefore, timeafter)).msgList; + + // 记录每个表情包的发送次数和发送者 + let countMap = new Map // 记录每个用户发送次数 + }>(); + + // 处理所有表情包和图片 + for (const msg of msgList) { + // 获取消息发送者 + const senderUin = msg.senderUin; + if (!senderUin) continue; + + // 提取消息中的表情包或图片 + const mediaElements = msg.elements.filter(e => e.marketFaceElement || e.picElement); + + for (const elem of mediaElements) { + let mediaPart = elem.marketFaceElement || elem.picElement; + if (!mediaPart) continue; + + if ('emojiId' in mediaPart) { + // 处理表情包 + const { emojiId } = mediaPart; + const dir = emojiId.substring(0, 2); + const url = `https://gxh.vip.qq.com/club/item/parcel/item/${dir}/${emojiId}/raw300.gif`; + + const existing = countMap.get(emojiId) || { count: 0, url, senders: new Map() }; + existing.count += 1; + existing.senders.set(senderUin, (existing.senders.get(senderUin) || 0) + 1); + countMap.set(emojiId, existing); + } else { + // 处理图片 + let unique = mediaPart.fileName || ""; + let existing = countMap.get(unique) || { count: 0, url: '', senders: new Map() }; + + if (!existing.url) { + existing.url = await _core.apis.FileApi.getImageUrl(mediaPart); + } + + existing.count += 1; + existing.senders.set(senderUin, (existing.senders.get(senderUin) || 0) + 1); + countMap.set(unique, existing); + } + } + } + + // 对表情包进行排名,取前10名 + let rank = Array.from(countMap.entries()) + .sort((a, b) => b[1].count - a[1].count) + .slice(0, 10); + + // 准备消息内容 + let msgContent: OB11MessageNode[] = []; + // 为每个表情包添加最爱发这个表情的人 + for (let i = 0; i < rank.length; i++) { + const item = rank[i]; + if (!item) continue; // 防御性检查 + + const [_unique, data] = item; + const { count, url, senders } = data; + + // 查找最常发送此表情包的用户 + const topSenders = Array.from(senders.entries()) + .sort((a, b) => b[1] - a[1]); + + if (topSenders.length > 0) { + const topSender = topSenders[0]; + const senderUin = topSender?.[0]; + const userCount = topSender?.[1]; + if (!senderUin || !userCount) continue; // 防御性检查 + // 获取用户昵称 + let senderInfo; + try { + senderInfo = await _core.apis.GroupApi.getGroupMember( + message.group_id?.toString() ?? "", + senderUin + ); + } catch (e) { + // 获取失败时使用QQ号 + } + + const nickname = senderInfo?.nick || senderUin; + const userPercent = ((userCount / count) * 100).toFixed(1); + + + + // 添加表情图片,带上发送者信息 + msgContent.push({ + type: OB11MessageDataType.node, + data: { + content: [ + { + type: OB11MessageDataType.text, + data: { + text: `${i + 1}. 表情使用${count}次 - ${nickname}发了${userCount}次(${userPercent}%)\n` + } + }, + { + type: OB11MessageDataType.image, + data: { + file: url + } + } + ] as OB11MessageData[] + } + }); + } + } + if (msgContent.length > 0) { + await action.get('send_group_msg')?.handle({ + group_id: String(message.group_id), + message: [{ + type: OB11MessageDataType.node, + data: { + content: [ + { + type: OB11MessageDataType.text, + data: { + text: '群友今日最爱表情包Top10' + } + } + ] + } + }, ...msgContent] + }, adapter, instance.config); + } else { + // 没有找到表情包时发送提示 + await action.get('send_group_msg')?.handle({ + group_id: String(message.group_id), + message: [{ + type: OB11MessageDataType.text, + data: { + text: '今日群里没有人发送表情包哦' + } + }] + }, adapter, instance.config); + } + } + else if (message.message.find(e => e.type == 'text' && e.data.text.startsWith('#Ta最爱的表情包'))) { + // 获取目标用户 + let text_msg = message.message.find(e => e.type == 'text')?.data.text; + let at_msg = message.message.find(e => e.type == 'at')?.data.qq; + if (!at_msg) { + at_msg = message.user_id.toString(); + } + if (!text_msg || !at_msg) return; + + // 获取用户历史消息 + let peer = { peerUid: message.group_id?.toString() ?? "", chatType: ChatType.KCHATTYPEGROUP }; + let sender_uid = await _core.apis.UserApi.getUidByUinV2(at_msg); + let msgs = (await _core.apis.MsgApi.queryFirstMsgBySender(peer, [sender_uid])).msgList; + + // 记录表情包使用频率 + let countMap = new Map(); + + // 处理所有消息中的表情 + for (const msg of msgs) { + // 提取消息中的表情包元素 + const mediaElements = msg.elements.filter(e => e.marketFaceElement || e.picElement); + + for (const elem of mediaElements) { + let mediaPart = elem.marketFaceElement || elem.picElement; + if (!mediaPart) continue; + + if ('emojiId' in mediaPart) { + // 处理表情包 + const { emojiId } = mediaPart; + const dir = emojiId.substring(0, 2); + const url = `https://gxh.vip.qq.com/club/item/parcel/item/${dir}/${emojiId}/raw300.gif`; + + const existing = countMap.get(emojiId) || { count: 0, url, lastUsed: 0 }; + existing.count += 1; + existing.lastUsed = Math.max(existing.lastUsed, parseInt(msg.msgTime || '0')); + countMap.set(emojiId, existing); + } else { + // 处理图片 + let unique = mediaPart.fileName || ""; + let existing = countMap.get(unique) || { count: 0, url: '', lastUsed: 0 }; + + if (!existing.url) { + existing.url = await _core.apis.FileApi.getImageUrl(mediaPart); + } + + existing.count += 1; + existing.lastUsed = Math.max(existing.lastUsed, parseInt(msg.msgTime || '0')); + countMap.set(unique, existing); + } + } + } + + // 对表情包进行排名,取前10名 + let rank = Array.from(countMap.entries()) + .sort((a, b) => b[1].count - a[1].count) + .slice(0, 10); + + // 获取用户信息 + let info = await _core.apis.GroupApi.getGroupMember(message.group_id?.toString() ?? "", at_msg.toString()); + + // 准备消息内容 + let msgContent: OB11MessageNode[] = []; + + // 为每个表情包生成一个节点 + for (let i = 0; i < rank.length; i++) { + const item = rank[i]; + if (!item) continue; + + const [_unique, data] = item; + const { count, url } = data; + + // 添加表情图片节点 + msgContent.push({ + type: OB11MessageDataType.node, + data: { + content: [ + { + type: OB11MessageDataType.text, + data: { + text: `${i + 1}. 使用了${count}次\n` + } + }, + { + type: OB11MessageDataType.image, + data: { + file: url + } + } + ] as OB11MessageData[] + } + }); + } + + if (msgContent.length > 0) { + await action.get('send_group_msg')?.handle({ + group_id: String(message.group_id), + message: [{ + type: OB11MessageDataType.node, + data: { + content: [ + { + type: OB11MessageDataType.text, + data: { + text: `${info?.nick || at_msg} 的最爱表情包Top${Math.min(10, rank.length)}` + } + } + ] + } + }, ...msgContent] + }, adapter, instance.config); + } else { + // 没有找到表情包时发送提示 + await action.get('send_group_msg')?.handle({ + group_id: String(message.group_id), + message: [{ + type: OB11MessageDataType.at, + data: { + qq: at_msg, + } + }, { + type: OB11MessageDataType.text, + data: { + text: ' 似乎没有发过表情包呢' + } + }] + }, adapter, instance.config); + } + } }; \ No newline at end of file diff --git a/src/plugin/wordcloud.ts b/src/plugin/wordcloud.ts new file mode 100644 index 00000000..bb3ebc4e --- /dev/null +++ b/src/plugin/wordcloud.ts @@ -0,0 +1,952 @@ +import { createCanvas } from '@napi-rs/canvas'; +interface WordFrequency { + word: string; + frequency: number; +} + +interface Position { + x: number; + y: number; + width: number; + height: number; + rotation: number; + fontSize: number; +} + +/** + * 根据词频生成词云图片 + * @param wordFrequencies 词频数组,包含单词和对应的频率 + * @param initialWidth 初始画布宽度(最终会自动调整) + * @param initialHeight 初始画布高度(最终会自动调整) + * @param options 词云配置选项 + * @returns 图片的base64编码字符串 + */ +export async function generateWordCloud( + wordFrequencies: WordFrequency[], + initialWidth = 1000, + initialHeight = 800, + options = { + backgroundColor: 'white', + enableRotation: true, + maxAttempts: 60, // 每个词的最大尝试次数 + minFontSize: 20, // 最小字体大小 + maxFontSize: 100, // 最大字体大小 + padding: 20, // 降低内边距提高紧凑度 + horizontalWeight: 0.6, // 提高横排权重增强可读性 + rotationVariance: 10, // 减少旋转角度变化 + safetyMargin: 6, // 减小安全距离以提高密度 + fontSizeRatio: 2.0, // 字体大小差异 + overlapThreshold: 0.10, // 允许10%的重叠 + maxExpansionAttempts: 10, // 最大画布扩展次数 + expansionRatio: 1.15 // 降低每次扩展比例 + } +): Promise { + // 空数组检查 + if (wordFrequencies.length === 0) { + const emptyCanvas = createCanvas(initialWidth, initialHeight); + const ctx = emptyCanvas.getContext('2d'); + ctx.fillStyle = options.backgroundColor; + ctx.fillRect(0, 0, initialWidth, initialHeight); + return "base64://" + emptyCanvas.toBuffer('image/png').toString('base64'); + } + + // 过滤不可渲染字符 - 增强过滤能力 + const filteredWordFrequencies = wordFrequencies.map(item => ({ + ...item, + word: filterUnrenderableChars(item.word) + })).filter(item => item.word.length > 0); + + // 再次检查过滤后是否为空 + if (filteredWordFrequencies.length === 0) { + const emptyCanvas = createCanvas(initialWidth, initialHeight); + const ctx = emptyCanvas.getContext('2d'); + ctx.fillStyle = options.backgroundColor; + ctx.fillRect(0, 0, initialWidth, initialHeight); + return "base64://" + emptyCanvas.toBuffer('image/png').toString('base64'); + } + + // 对词频进行排序,频率高的先绘制 + const sortedWords = [...filteredWordFrequencies].sort((a, b) => b.frequency - a.frequency); + + // 计算最小和最大频率,用于字体大小缩放 + const maxFreq = sortedWords[0]?.frequency || 1; + const minFreq = sortedWords[sortedWords.length - 1]?.frequency || 1; + const freqRange = Math.max(1, maxFreq - minFreq); // 避免除以零 + + // 检查字符类型 + const isChineseChar = (char: string): boolean => /[\u4e00-\u9fa5]/.test(char); + const isEnglishChar = (char: string): boolean => /[a-zA-Z0-9]/.test(char); + + // 判断单词类型(中文、英文或混合) + const getWordType = (word: string): 'chinese' | 'english' | 'mixed' => { + let hasChinese = false; + let hasEnglish = false; + + for (const char of word) { + if (isChineseChar(char)) hasChinese = true; + else if (isEnglishChar(char)) hasEnglish = true; + + if (hasChinese && hasEnglish) return 'mixed'; + } + + return hasChinese ? 'chinese' : 'english'; + }; + + // 获取适合单词的字体 + const getFontFamily = (word: string): string => { + const wordType = getWordType(word); + + if (wordType === 'chinese') return '"Aa偷吃可爱长大的", sans-serif'; + if (wordType === 'english') return '"JetBrains Mono", monospace'; + return '"Aa偷吃可爱长大的", "JetBrains Mono", sans-serif'; // 混合类型 + }; + + // 增强的字体大小计算函数,保持高频词更大但减小差距 + const calculateFontSize = (frequency: number, index: number): number => { + // 基本的频率比例 + const frequencyRatio = (frequency - minFreq) / freqRange; + + // 根据词云大小调整差异系数 + const smallCloudFactor = sortedWords.length < 15 ? 1.5 : 1.0; // 小词云时增大差异 + + // 应用非线性映射,使高频词更大但差距不过大 + let sizeRatio; + + if (index === 0) { + // 最高频词 + sizeRatio = Math.pow(frequencyRatio, 0.3) * 2.2 * smallCloudFactor; + } else if (index < sortedWords.length * 0.05) { + // 前5%的高频词 + sizeRatio = Math.pow(frequencyRatio, 0.4) * 1.8 * smallCloudFactor; + } else if (index < sortedWords.length * 0.15) { + // 前15%的高频词 + sizeRatio = Math.pow(frequencyRatio, 0.5) * 1.5 * smallCloudFactor; + } else if (index < sortedWords.length * 0.3) { + // 前30%的高频词 + sizeRatio = Math.pow(frequencyRatio, 0.6) * 1.3 * smallCloudFactor; + } else { + // 其余的词 + sizeRatio = Math.pow(frequencyRatio, 0.7) * 1.0 * smallCloudFactor; + } + + // 应用配置的字体大小比例系数 + sizeRatio *= options.fontSizeRatio; + + // 计算最终字体大小 + return Math.max( + options.minFontSize, + Math.min( + options.maxFontSize, + Math.floor(options.minFontSize + sizeRatio * (options.maxFontSize - options.minFontSize)) + ) + ); + }; + + // 获取基于词频的颜色 + const getColorFromFrequency = (frequency: number, index: number): string => { + // 使用词频和索引生成不同的色相值 + const hue = (index * 137.5) % 360; // 黄金角分布 + + // 重要词使用更醒目的颜色 + let saturation, lightness; + + if (index === 0) { + // 最高频词 + saturation = 95; + lightness = 45; + } else if (index < sortedWords.length * 0.1) { + // 前10%的高频词 + saturation = 90; + lightness = 45; + } else { + // 降低其他词的饱和度,增加整体和谐性 + saturation = 75 + (Math.max(0.3, frequency / maxFreq) * 15); + lightness = 40 + (Math.max(0.3, frequency / maxFreq) * 15); + } + + return `hsl(${hue}, ${saturation}%, ${lightness}%)`; + }; + + // 临时画布用于测量文本 + let tempCanvas = createCanvas(initialWidth, initialHeight); + let tempCtx = tempCanvas.getContext('2d'); + + // 已确定位置的单词数组 + const placedWords: Position[] = []; + + // 根据旋转角度计算包围盒(用于碰撞检测) + const getRotatedBoundingBox = (x: number, y: number, width: number, height: number, rotation: number) => { + // 转换角度为弧度 + const rad = rotation * Math.PI / 180; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + + // 计算四个角的坐标 + const corners = [ + { x: -width / 2, y: -height / 2 }, + { x: width / 2, y: -height / 2 }, + { x: width / 2, y: height / 2 }, + { x: -width / 2, y: height / 2 } + ].map(pt => { + return { + x: x + width / 2 + (pt.x * cos - pt.y * sin), + y: y + height / 2 + (pt.x * sin + pt.y * cos) + }; + }); + + // 计算包围盒 + const boxMinX = Math.min(...corners.map(c => c.x)); + const boxMaxX = Math.max(...corners.map(c => c.x)); + const boxMinY = Math.min(...corners.map(c => c.y)); + const boxMaxY = Math.max(...corners.map(c => c.y)); + + return { minX: boxMinX, maxX: boxMaxX, minY: boxMinY, maxY: boxMaxY }; + }; + + // 精确重叠检测 - 允许适度重叠,并考虑词的重要性 + const isOverlapping = (x: number, y: number, width: number, height: number, rotation: number, index: number): boolean => { + // 获取当前词的包围盒 + const currentBox = getRotatedBoundingBox(x, y, width, height, rotation); + + // 为边缘增加安全距离,根据重要性调整 + const safetyMargin = index < sortedWords.length * 0.05 ? + options.safetyMargin * 1.2 : options.safetyMargin * 0.9; + + const safetyBox = { + minX: currentBox.minX - safetyMargin, + maxX: currentBox.maxX + safetyMargin, + minY: currentBox.minY - safetyMargin, + maxY: currentBox.maxY + safetyMargin + }; + + // 计算当前单词的面积 + const currentArea = (safetyBox.maxX - safetyBox.minX) * (safetyBox.maxY - safetyBox.minY); + + // 为高频词设置更严格的重叠阈值 + const overlapThreshold = index < sortedWords.length * 0.1 ? + options.overlapThreshold * 0.6 : options.overlapThreshold; + + // 检查是否与已放置的词重叠超过阈值 + for (const pos of placedWords) { + const posBox = getRotatedBoundingBox( + pos.x, pos.y, pos.width, pos.height, pos.rotation + ); + + // 计算重叠区域 + const overlapX = Math.max(0, Math.min(safetyBox.maxX, posBox.maxX) - Math.max(safetyBox.minX, posBox.minX)); + const overlapY = Math.max(0, Math.min(safetyBox.maxY, posBox.maxY) - Math.max(safetyBox.minY, posBox.minY)); + const overlapArea = overlapX * overlapY; + + // 计算重叠率 + const overlapRatio = overlapArea / currentArea; + + // 如果重叠率超过阈值,则认为重叠 + if (overlapRatio > overlapThreshold) { + return true; + } + } + + return false; + }; + + // 获取当前词云形状信息 - 改进密度计算 + const getCloudShape = () => { + if (placedWords.length === 0) { + return { + width: initialWidth, + height: initialHeight, + ratio: initialWidth / initialHeight, + density: 0 + }; + } + + // 计算已放置区域的边界 + let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; + let totalArea = 0; + + placedWords.forEach(pos => { + const box = getRotatedBoundingBox(pos.x, pos.y, pos.width, pos.height, pos.rotation); + minX = Math.min(minX, box.minX); + maxX = Math.max(maxX, box.maxX); + minY = Math.min(minY, box.minY); + maxY = Math.max(maxY, box.maxY); + + // 累计词的面积 + totalArea += pos.width * pos.height; + }); + + const width = Math.max(1, maxX - minX); + const height = Math.max(1, maxY - minY); + const area = width * height; + + // 计算密度 (已用面积 / 总面积) + const density = totalArea / area; + + return { + width, + height, + ratio: width / height, + density + }; + }; + + // 自适应旋转角度决策 - 基于可用空间和单词特性 + const getOptimalRotation = (word: string, textWidth: number, textHeight: number, index: number) => { + if (!options.enableRotation) return 0; + + const wordType = getWordType(word); + const isHighFrequencyWord = index < sortedWords.length * 0.15; // 前15%的高频词 + + // 单字或短词偏好水平排列 + if (word.length === 1 || (wordType === 'english' && word.length <= 3)) { + return 0; + } + + // 中文单字不旋转 + if (wordType === 'chinese' && word.length === 1) { + return 0; + } + + // 高频词优先水平排列 + if (isHighFrequencyWord) { + // 最高频词不旋转 + if (index === 0) return 0; + // 其他高频词轻微旋转 + return (Math.random() * 2 - 1) * 3; + } + + // 获取当前词云形状与密度 + const cloudShape = getCloudShape(); + + // 特别定制:根据词的宽高比决定旋转 + // 细长的词在排版上更灵活 + const isLongWord = textWidth / textHeight > 3; + + // 宽高比例调整旋转策略 + if (cloudShape.ratio > 1.3) { + // 宽大于高,优先考虑竖排 + if (isLongWord) { + // 细长词适合90度旋转 + return 90; + } else { + // 其他词随机选择,但偏向竖排 + return Math.random() < 0.7 ? + 90 + (Math.random() * 2 - 1) * 5 : // 竖排 + (Math.random() * 2 - 1) * 5; // 横排 + } + } else if (cloudShape.ratio < 0.7) { + // 高大于宽,优先考虑横排 + return (Math.random() * 2 - 1) * 5; + } + + // 根据词的类型进一步决定倾向 + let horizontalBias = options.horizontalWeight; + + if (wordType === 'chinese' && word.length > 1) { + // 中文词组更适合横排 + horizontalBias += 0.2; + } else if (wordType === 'english' && word.length > 5) { + // 长英文单词可增加竖排几率 + horizontalBias -= 0.1; + } + + // 根据词的长宽比进一步微调 + const aspectRatio = textWidth / textHeight; + if (aspectRatio > 4) { + // 极细长的词更适合竖排 + horizontalBias -= 0.25; + } else if (aspectRatio < 1.5) { + // 近方形的词更适合横排 + horizontalBias += 0.15; + } + + // 最终决定旋转 + return Math.random() < horizontalBias ? + (Math.random() * 2 - 1) * options.rotationVariance / 3 : // 横排,减小角度变化 + 90 + (Math.random() * 2 - 1) * options.rotationVariance / 3; // 竖排,减小角度变化 + }; + + // 增强的过滤不可渲染字符函数 + function filterUnrenderableChars(text: string): string { + // 过滤掉控制字符、特殊Unicode和一些可能导致渲染问题的字符 + return text + .replace(/[\u0000-\u001F\u007F-\u009F\uFFFD\uFFFE\uFFFF]/g, '') // 控制字符和特殊字符 + .replace(/[\u2000-\u200F\u2028-\u202F]/g, '') // 一些特殊空白和控制字符 + .replace(/[\u0080-\u00A0]/g, '') // 一些Latin-1补充字符 + .replace(/[^\p{L}\p{N}\p{P}\p{Z}]/gu, '') // 只保留字母、数字、标点和空格 + .trim(); + } + + // ===== 优化: 添加新的位置策略函数 ===== + + // 改进的螺旋布局 - 更紧凑的布局策略 + const getSpiralPosition = ( + textWidth: number, + textHeight: number, + attempt: number, + canvasShape: { width: number, height: number, ratio: number, density: number } + ) => { + // 根据词数量调整螺旋参数 - 词少时更紧凑 + const wordCountFactor = Math.min(1, placedWords.length / 20); // 少于20个词时更紧凑 + + // 使用已放置词的中心点,而非固定画布中心 + let centerX = initialWidth / 2; + let centerY = initialHeight / 2; + + // 如果已经有足够的词,使用它们的质心作为新的中心点 + if (placedWords.length >= 3) { + let sumX = 0, sumY = 0, weightSum = 0; + for (const pos of placedWords) { + // 较大的词有更大的权重影响中心点 + const weight = Math.sqrt(pos.width * pos.height); + sumX += (pos.x + pos.width / 2) * weight; + sumY += (pos.y + pos.height / 2) * weight; + weightSum += weight; + } + centerX = sumX / weightSum; + centerY = sumY / weightSum; + } + + // 动态调整螺旋参数,词数少时更紧凑 + const baseA = Math.min(initialWidth, initialHeight) / (35 + (1 - wordCountFactor) * 15); + const densityFactor = Math.max(0.7, Math.min(1.4, 0.7 + canvasShape.density * 1.2)); + const a = baseA / densityFactor; // 反比例,密度高时参数更小,螺旋更紧凑 + + // 词数量少时使用更小的角度增量,产生更紧凑的螺旋 + const angleIncrement = 0.1 + wordCountFactor * 0.25; + const angle = angleIncrement * attempt; + + // 非线性距离增长,词数少时增长更慢 + const distanceMultiplier = wordCountFactor * ( + attempt < 8 ? + 0.2 + Math.pow(attempt / 8, 1.5) : // 前几次更靠近中心,呈幂次增长 + 0.7 + Math.pow((attempt - 8) / 25, 0.7) // 之后缓慢增长 + ) + (1 - wordCountFactor) * (0.1 + Math.pow(attempt / 20, 1.2)); // 词少时增长更慢 + + // 根据画布形状自适应调整螺旋方向 + let dx, dy; + if (canvasShape.ratio > 1.2) { // 宽大于高 + // 水平方向拉伸,但减少拉伸强度 + dx = a * angle * Math.cos(angle) * distanceMultiplier * 1.1; + dy = a * angle * Math.sin(angle) * distanceMultiplier * 0.9; + } else if (canvasShape.ratio < 0.8) { // 高大于宽 + // 垂直方向拉伸,但减少拉伸强度 + dx = a * angle * Math.cos(angle) * distanceMultiplier * 0.9; + dy = a * angle * Math.sin(angle) * distanceMultiplier * 1.1; + } else { + // 更均衡的螺旋 + dx = a * angle * Math.cos(angle) * distanceMultiplier; + dy = a * angle * Math.sin(angle) * distanceMultiplier; + } + + // 添加少量随机抖动以打破规则性 + dx += (Math.random() - 0.5) * a * 0.5; + dy += (Math.random() - 0.5) * a * 0.5; + + const x = centerX + dx - textWidth / 2; + const y = centerY + dy - textHeight / 2; + + return { + x: Math.max(options.safetyMargin, Math.min(initialWidth - textWidth - options.safetyMargin, x)), + y: Math.max(textHeight / 2 + options.safetyMargin, Math.min(initialHeight - textHeight / 2 - options.safetyMargin, y)) + }; + }; + + + // 新增: 空白区域填充策略 + const findGapPosition = ( + textWidth: number, + textHeight: number, + canvasShape: { width: number, height: number, ratio: number, density: number } + ) => { + // 如果词太少,直接返回螺旋位置 + if (placedWords.length < 5) { + return getSpiralPosition(textWidth, textHeight, Math.floor(Math.random() * 10), canvasShape); + } + + // 计算当前词云的边界 + let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; + + for (const pos of placedWords) { + const box = getRotatedBoundingBox(pos.x, pos.y, pos.width, pos.height, pos.rotation); + minX = Math.min(minX, box.minX); + maxX = Math.max(maxX, box.maxX); + minY = Math.min(minY, box.minY); + maxY = Math.max(maxY, box.maxY); + } + + // 定义搜索区域,适当扩大范围 + const searchMargin = Math.max(textWidth, textHeight) * 0.5; + const searchMinX = Math.max(0, minX - searchMargin); + const searchMaxX = Math.min(initialWidth, maxX + searchMargin); + const searchMinY = Math.max(0, minY - searchMargin); + const searchMaxY = Math.min(initialHeight, maxY + searchMargin); + + // 搜索区域宽高 + const searchWidth = searchMaxX - searchMinX; + const searchHeight = searchMaxY - searchMinY; + + // 网格尺寸,较小的网格可以更精确地找到空白区域 + const gridSize = Math.min(textWidth, textHeight) / 2; + const gridRows = Math.max(3, Math.ceil(searchHeight / gridSize)); + const gridCols = Math.max(3, Math.ceil(searchWidth / gridSize)); + + // 初始化网格密度 + const gridDensity = Array(gridRows).fill(0).map(() => Array(gridCols).fill(0)); + + // 计算每个网格的密度 + for (const pos of placedWords) { + const box = getRotatedBoundingBox(pos.x, pos.y, pos.width, pos.height, pos.rotation); + + // 计算此词覆盖的网格范围 + const startRow = Math.max(0, Math.floor((box.minY - searchMinY) / gridSize)); + const endRow = Math.min(gridRows - 1, Math.floor((box.maxY - searchMinY) / gridSize)); + const startCol = Math.max(0, Math.floor((box.minX - searchMinX) / gridSize)); + const endCol = Math.min(gridCols - 1, Math.floor((box.maxX - searchMinX) / gridSize)); + + // 增加网格密度值 + for (let r = startRow; r <= endRow; r++) { + for (let c = startCol; c <= endCol; c++) { + if (r >= 0 && r < gridRows && c >= 0 && c < gridCols) { + gridDensity[r]![c] += 1; + } + } + } + } + + // 找出能容纳当前词的最低密度区域 + let bestDensity = Infinity; + let bestRow = 0, bestCol = 0; + + // 需要的网格数量 + const needRows = Math.ceil(textHeight / gridSize); + const needCols = Math.ceil(textWidth / gridSize); + + // 搜索最优位置 + for (let r = 0; r <= gridRows - needRows; r++) { + for (let c = 0; c <= gridCols - needCols; c++) { + let totalDensity = 0; + let isValid = true; + + // 计算区域总密度 + for (let nr = 0; nr < needRows && isValid; nr++) { + for (let nc = 0; nc < needCols && isValid; nc++) { + if (r + nr < gridRows && c + nc < gridCols) { + totalDensity += gridDensity[r + nr]![c + nc]; + + // 如果单个网格密度过高,直接判定无效 + if (gridDensity[r + nr]![c + nc] > 3) { + isValid = false; + } + } + } + } + + // 更新最佳位置 + if (isValid && totalDensity < bestDensity) { + bestDensity = totalDensity; + bestRow = r; + bestCol = c; + } + } + } + + // 添加随机抖动避免太规则 + const jitterX = (Math.random() - 0.5) * gridSize * 0.6; + const jitterY = (Math.random() - 0.5) * gridSize * 0.6; + + // 计算最终位置 + const x = searchMinX + bestCol * gridSize + jitterX; + const y = searchMinY + bestRow * gridSize + jitterY; + + return { + x: Math.max(options.safetyMargin, Math.min(initialWidth - textWidth - options.safetyMargin, x)), + y: Math.max(textHeight + options.safetyMargin, Math.min(initialHeight - options.safetyMargin, y)) + }; + }; + + // 新增: 边缘扩展策略 + const getEdgeExtendPosition = ( + textWidth: number, + textHeight: number, + attempt: number, + canvasShape: { width: number, height: number, ratio: number, density: number } + ) => { + // 如果无已放置词,回退到螺旋 + if (placedWords.length === 0) { + return getSpiralPosition(textWidth, textHeight, attempt, canvasShape); + } + + // 随机选择一个已放置的词作为参考点 + const referenceIndex = Math.floor(Math.random() * placedWords.length); + const reference = placedWords[referenceIndex]; + + // 随机选择方向 (0=右, 1=下, 2=左, 3=上,4-7=对角线) + const direction = Math.floor(Math.random() * 8); + + // 基础位置 + let baseX = reference!.x; + let baseY = reference!.y; + + // 获取参考词的旋转后边界框 + const refBox = getRotatedBoundingBox( + reference!.x, reference!.y, reference!.width, reference!.height, reference!.rotation + ); + + // 根据方向计算新位置 + const margin = options.safetyMargin * 0.5; // 减小边距,增加紧凑度 + + switch (direction) { + case 0: // 右 + baseX = refBox.maxX + margin; + baseY = refBox.minY + (refBox.maxY - refBox.minY) / 2 - textHeight / 2; + break; + case 1: // 下 + baseX = refBox.minX + (refBox.maxX - refBox.minX) / 2 - textWidth / 2; + baseY = refBox.maxY + margin; + break; + case 2: // 左 + baseX = refBox.minX - textWidth - margin; + baseY = refBox.minY + (refBox.maxY - refBox.minY) / 2 - textHeight / 2; + break; + case 3: // 上 + baseX = refBox.minX + (refBox.maxX - refBox.minX) / 2 - textWidth / 2; + baseY = refBox.minY - textHeight - margin; + break; + case 4: // 右上 + baseX = refBox.maxX + margin; + baseY = refBox.minY - textHeight - margin; + break; + case 5: // 右下 + baseX = refBox.maxX + margin; + baseY = refBox.maxY + margin; + break; + case 6: // 左下 + baseX = refBox.minX - textWidth - margin; + baseY = refBox.maxY + margin; + break; + case 7: // 左上 + baseX = refBox.minX - textWidth - margin; + baseY = refBox.minY - textHeight - margin; + break; + } + + // 添加少量随机抖动 + baseX += (Math.random() - 0.5) * margin * 2; + baseY += (Math.random() - 0.5) * margin * 2; + + return { + x: Math.max(options.safetyMargin, Math.min(initialWidth - textWidth - options.safetyMargin, baseX)), + y: Math.max(textHeight + options.safetyMargin, Math.min(initialHeight - options.safetyMargin, baseY)) + }; + }; + + // 新增: 多策略选择函数,根据情况选择最佳策略 + const getPositionWithStrategy = ( + textWidth: number, + textHeight: number, + attempt: number, + canvasShape: { width: number, height: number, ratio: number, density: number }, + index: number + ) => { + // 检测是否为小词云 + const isSmallWordCloud = sortedWords.length < 15; + + // 第一个词或前几个高频词仍然放在中心,小词云时范围更大 + if (placedWords.length === 0 || (index < 3 && attempt < 5) || (isSmallWordCloud && index < Math.min(5, sortedWords.length / 2))) { + // 添加小偏移以避免完全重叠 + const offset = isSmallWordCloud ? index * 8 : 0; + return { + x: initialWidth / 2 - textWidth / 2 + (Math.random() - 0.5) * offset, + y: initialHeight / 2 - textHeight / 2 + (Math.random() - 0.5) * offset + }; + } + + // 根据尝试次数选择不同策略 + const attemptProgress = attempt / options.maxAttempts; // 0到1的进度值 + + // 小词云优先使用紧凑布局策略 + if (isSmallWordCloud) { + if (attemptProgress < 0.6) { + return getSpiralPosition(textWidth, textHeight, attempt / 2, canvasShape); // 减少螺旋步长,更紧凑 + } else { + return Math.random() < 0.7 ? + findGapPosition(textWidth, textHeight, canvasShape) : + getEdgeExtendPosition(textWidth, textHeight, attempt / 2, canvasShape); + } + } + + // 高频词优先使用螺旋或中心布局 + if (index < sortedWords.length * 0.1) { + if (attemptProgress < 0.5) { + return getSpiralPosition(textWidth, textHeight, attempt, canvasShape); + } else { + return Math.random() < 0.7 ? + findGapPosition(textWidth, textHeight, canvasShape) : + getEdgeExtendPosition(textWidth, textHeight, attempt, canvasShape); + } + } + + // 不同阶段使用不同策略 + if (attemptProgress < 0.3) { + // 前30%尝试: 主要使用改进的螺旋 + return getSpiralPosition(textWidth, textHeight, attempt, canvasShape); + } else if (attemptProgress < 0.7) { + // 中间40%尝试: 主要寻找空白区域 + return Math.random() < 0.8 ? + findGapPosition(textWidth, textHeight, canvasShape) : + getSpiralPosition(textWidth, textHeight, attempt, canvasShape); + } else { + // 后30%尝试: 主要使用边缘扩展和随机策略 + const r = Math.random(); + if (r < 0.6) { + return getEdgeExtendPosition(textWidth, textHeight, attempt, canvasShape); + } else if (r < 0.8) { + return findGapPosition(textWidth, textHeight, canvasShape); + } else { + return getSpiralPosition(textWidth, textHeight, attempt * 2, canvasShape); // 双倍螺旋步进,迅速扩展 + } + } + }; + + // 记录所有单词的边界以自动调整画布大小 + let minX = initialWidth; + let maxX = 0; + let minY = initialHeight; + let maxY = 0; + + // 记录原始中心点,用于居中重定位 + let originalCenterX = initialWidth / 2; + let originalCenterY = initialHeight / 2; + + // 动态画布扩展计数 + let canvasExpansionCount = 0; + + // 第一轮:计算每个单词的位置并追踪边界 + for (let i = 0; i < sortedWords.length; i++) { + const { word, frequency } = sortedWords[i]!; + + // 安全检查 - 过滤不可渲染字符 + const safeWord = filterUnrenderableChars(word); + if (!safeWord) continue; + + // 使用增强的字体大小计算函数 + const fontSize = calculateFontSize(frequency, i); + + // 获取合适的字体 + const fontFamily = getFontFamily(safeWord); + + // 设置字体和测量文本 + tempCtx.font = `bold ${fontSize}px ${fontFamily}`; + const metrics = tempCtx.measureText(safeWord); + + // 更精确地计算文本高度 + const textHeight = fontSize; + const textWidth = metrics.width; + + // 获取当前云形状与密度 + const cloudShape = getCloudShape(); + + // 获取最佳旋转角度 + const rotation = getOptimalRotation(safeWord, textWidth, textHeight, i); + + // 尝试定位 + let positioned = false; + let finalX = 0, finalY = 0; + + // 尝试放置单词,如果失败可能会扩展画布 + for (let attempt = 0; attempt < options.maxAttempts && !positioned; attempt++) { + // 使用多策略获取位置,而不是仅用螺旋布局 + const { x, y } = getPositionWithStrategy(textWidth, textHeight, attempt, cloudShape, i); + + if (!isOverlapping(x, y, textWidth, textHeight, rotation, i)) { + finalX = x; + finalY = y; + positioned = true; + + // 获取此单词旋转后的包围盒 + const box = getRotatedBoundingBox(x, y, textWidth, textHeight, rotation); + + // 更新整体边界 + minX = Math.min(minX, box.minX); + maxX = Math.max(maxX, box.maxX); + minY = Math.min(minY, box.minY); + maxY = Math.max(maxY, box.maxY); + + // 记录位置,保存字体大小 + placedWords.push({ + x: finalX, + y: finalY, + width: textWidth, + height: textHeight, + rotation, + fontSize + }); + } else if (attempt === options.maxAttempts - 1 && canvasExpansionCount < options.maxExpansionAttempts) { + // 如果所有尝试都失败,并且还有扩展余量,则扩展画布 + canvasExpansionCount++; + + // 计算当前中心点 + const currentCenterX = (maxX + minX) / 2; + const currentCenterY = (maxY + minY) / 2; + + // 保存原始画布尺寸 + const oldWidth = initialWidth; + const oldHeight = initialHeight; + + // 扩展画布尺寸 - 使用更小的扩展比例 + initialWidth = Math.ceil(initialWidth * options.expansionRatio); + initialHeight = Math.ceil(initialHeight * options.expansionRatio); + + // 计算扩展量 + const widthIncrease = initialWidth - oldWidth; + const heightIncrease = initialHeight - oldHeight; + + // 调整所有已放置单词的位置,使它们保持居中 + placedWords.forEach(pos => { + // 相对于原中心的偏移 + const offsetX = pos.x - originalCenterX; + const offsetY = pos.y - originalCenterY; + + // 计算新位置,保持相对于中心的偏移不变 + pos.x = originalCenterX + widthIncrease / 2 + offsetX; + pos.y = originalCenterY + heightIncrease / 2 + offsetY; + }); + + // 更新坐标系中心点 + originalCenterX = originalCenterX + widthIncrease / 2; + originalCenterY = originalCenterY + heightIncrease / 2; + + // 更新边界信息 + minX += widthIncrease / 2; + maxX += widthIncrease / 2; + minY += heightIncrease / 2; + maxY += heightIncrease / 2; + + // 重新创建临时画布 + tempCanvas = createCanvas(initialWidth, initialHeight); + tempCtx = tempCanvas.getContext('2d'); + + // 重置尝试计数,在新的扩展画布上再次尝试 + attempt = -1; // 会在循环中+1变成0 + } + } + + // 如果仍然无法放置,尝试增加重叠容忍度 + if (!positioned) { + const maxOverlapThreshold = options.overlapThreshold * 2.0; // 允许更多重叠 + + for (let attempt = 0; attempt < options.maxAttempts && !positioned; attempt++) { + // 再次使用多策略获取位置 + const { x, y } = getPositionWithStrategy(textWidth, textHeight, attempt, cloudShape, i); + + // 获取当前词的包围盒 + const currentBox = getRotatedBoundingBox(x, y, textWidth, textHeight, rotation); + + // 计算当前单词的面积 + const currentArea = (currentBox.maxX - currentBox.minX) * (currentBox.maxY - currentBox.minY); + + // 计算最大重叠面积 + let maxOverlapArea = 0; + + for (const pos of placedWords) { + const posBox = getRotatedBoundingBox( + pos.x, pos.y, pos.width, pos.height, pos.rotation + ); + + // 计算重叠区域 + const overlapX = Math.max(0, Math.min(currentBox.maxX, posBox.maxX) - Math.max(currentBox.minX, posBox.minX)); + const overlapY = Math.max(0, Math.min(currentBox.maxY, posBox.maxY) - Math.max(currentBox.minY, posBox.minY)); + const overlapArea = overlapX * overlapY; + + maxOverlapArea = Math.max(maxOverlapArea, overlapArea); + } + + // 计算重叠率 + const overlapRatio = maxOverlapArea / currentArea; + + // 如果重叠率在允许范围内,则放置 + if (overlapRatio <= maxOverlapThreshold) { + finalX = x; + finalY = y; + positioned = true; + + // 获取此单词旋转后的包围盒 + const box = getRotatedBoundingBox(x, y, textWidth, textHeight, rotation); + + // 更新整体边界(即使是增加重叠度放置的单词也计入边界) + minX = Math.min(minX, box.minX); + maxX = Math.max(maxX, box.maxX); + minY = Math.min(minY, box.minY); + maxY = Math.max(maxY, box.maxY); + + // 记录位置 + placedWords.push({ + x: finalX, + y: finalY, + width: textWidth, + height: textHeight, + rotation, + fontSize + }); + } + } + + // 如果仍然无法放置,则跳过该词 + if (!positioned) { + console.log(`无法放置单词: ${safeWord}`); + continue; + } + } + } + + // 第二阶段:确定最终画布大小并绘制 + // 添加内边距 + minX = Math.max(0, minX - options.padding); + minY = Math.max(0, minY - options.padding); + maxX = maxX + options.padding; + maxY = maxY + options.padding; + + // 计算最终画布尺寸 + const finalWidth = Math.ceil(maxX - minX); + const finalHeight = Math.ceil(maxY - minY); + + // 创建最终画布 + const canvas = createCanvas(finalWidth, finalHeight); + const ctx = canvas.getContext('2d'); + + // 设置背景 + ctx.fillStyle = options.backgroundColor; + ctx.fillRect(0, 0, finalWidth, finalHeight); + + for (let i = 0; i < sortedWords.length; i++) { + if (i >= placedWords.length) continue; + + const { word, frequency } = sortedWords[i]!; + const position = placedWords[i]; + if (!position) continue; + + const safeWord = filterUnrenderableChars(word); + if (!safeWord) continue; + + const fontFamily = getFontFamily(safeWord); + + ctx.font = `bold ${position.fontSize}px ${fontFamily}`; + ctx.fillStyle = getColorFromFrequency(frequency, i); + + const adjustedX = position.x - minX; + const adjustedY = position.y - minY; + + ctx.save(); + ctx.translate( + adjustedX + position.width / 2, + adjustedY + position.height / 2 + ); + ctx.rotate(position.rotation * Math.PI / 180); + ctx.fillText(safeWord, -position.width / 2, position.height / 2); + ctx.restore(); + } + + const buffer = canvas.toBuffer('image/png'); + return "base64://" + buffer.toString('base64'); +} \ No newline at end of file diff --git a/src/shell/drawJson.ts b/src/shell/drawJson.ts new file mode 100644 index 00000000..174cf409 --- /dev/null +++ b/src/shell/drawJson.ts @@ -0,0 +1,77 @@ +import { createCanvas, loadImage } from "@napi-rs/canvas"; + +export async function drawJsonContent(jsonContent: string) { + const lines = jsonContent.split('\n'); + + const padding = 40; + const lineHeight = 30; + const canvas = createCanvas(1, 1); + const ctx = canvas.getContext('2d'); + + let maxLineWidth = 0; + for (const line of lines) { + let lineWidth = 0; + for (const char of line) { + const isChinese = /[\u4e00-\u9fa5]/.test(char); + ctx.font = isChinese ? '20px "Aa偷吃可爱长大的"' : '20px "JetBrains Mono"'; + lineWidth += ctx.measureText(char).width; + } + if (lineWidth > maxLineWidth) { + maxLineWidth = lineWidth; + } + } + + const width = maxLineWidth + padding * 2; + const height = lines.length * lineHeight + padding * 2; + + const finalCanvas = createCanvas(width, height); + const finalCtx = finalCanvas.getContext('2d'); + + const backgroundImage = await loadImage('C:\\fonts\\post.jpg'); + const pattern = finalCtx.createPattern(backgroundImage, 'repeat'); + finalCtx.fillStyle = pattern; + finalCtx.fillRect(0, 0, width, height); + + finalCtx.filter = 'blur(5px)'; + finalCtx.drawImage(finalCanvas, 0, 0); + + finalCtx.filter = 'none'; + const cardWidth = width - padding; + const cardHeight = height - padding; + const cardX = padding / 2; + const cardY = padding / 2; + const radius = 20; + + finalCtx.fillStyle = 'rgba(255, 255, 255, 0.8)'; + finalCtx.beginPath(); + finalCtx.moveTo(cardX + radius, cardY); + finalCtx.lineTo(cardX + cardWidth - radius, cardY); + finalCtx.quadraticCurveTo(cardX + cardWidth, cardY, cardX + cardWidth, cardY + radius); + finalCtx.lineTo(cardX + cardWidth, cardY + cardHeight - radius); + finalCtx.quadraticCurveTo(cardX + cardWidth, cardY + cardHeight, cardX + cardWidth - radius, cardY + cardHeight); + finalCtx.lineTo(cardX + radius, cardY + cardHeight); + finalCtx.quadraticCurveTo(cardX, cardY + cardHeight, cardX, cardY + cardHeight - radius); + finalCtx.lineTo(cardX, cardY + radius); + finalCtx.quadraticCurveTo(cardX, cardY, cardX + radius, cardY); + finalCtx.closePath(); + finalCtx.fill(); + + // 绘制 JSON 内容 + finalCtx.fillStyle = 'black'; + let textY = cardY + 40; + + for (const line of lines) { + let x = cardX + 20; + for (const char of line) { + const isChinese = /[\u4e00-\u9fa5]/.test(char); + finalCtx.font = isChinese ? '20px "Aa偷吃可爱长大的"' : '20px "JetBrains Mono"'; + finalCtx.fillText(char, x, textY); + x += finalCtx.measureText(char).width; + } + textY += 30; + } + + // 保存图像 + const buffer = finalCanvas.toBuffer('image/png'); + return "base64://" + buffer.toString('base64'); +} \ No newline at end of file diff --git a/src/shell/napcat.ts b/src/shell/napcat.ts index 2a93e51d..a8870097 100644 --- a/src/shell/napcat.ts +++ b/src/shell/napcat.ts @@ -1,82 +1,5 @@ import { NCoreInitShell } from './base'; -import { createCanvas, GlobalFonts, loadImage } from '@napi-rs/canvas'; +import { GlobalFonts } from '@napi-rs/canvas'; GlobalFonts.registerFromPath('C:\\fonts\\JetBrainsMono.ttf', 'JetBrains Mono'); GlobalFonts.registerFromPath('C:\\fonts\\AaCute.ttf', 'Aa偷吃可爱长大的'); - -export async function drawJsonContent(jsonContent: string) { - const lines = jsonContent.split('\n'); - - const padding = 40; - const lineHeight = 30; - const canvas = createCanvas(1, 1); - const ctx = canvas.getContext('2d'); - - let maxLineWidth = 0; - for (const line of lines) { - let lineWidth = 0; - for (const char of line) { - const isChinese = /[\u4e00-\u9fa5]/.test(char); - ctx.font = isChinese ? '20px "Aa偷吃可爱长大的"' : '20px "JetBrains Mono"'; - lineWidth += ctx.measureText(char).width; - } - if (lineWidth > maxLineWidth) { - maxLineWidth = lineWidth; - } - } - - const width = maxLineWidth + padding * 2; - const height = lines.length * lineHeight + padding * 2; - - const finalCanvas = createCanvas(width, height); - const finalCtx = finalCanvas.getContext('2d'); - - const backgroundImage = await loadImage('C:\\fonts\\post.jpg'); - const pattern = finalCtx.createPattern(backgroundImage, 'repeat'); - finalCtx.fillStyle = pattern; - finalCtx.fillRect(0, 0, width, height); - - finalCtx.filter = 'blur(5px)'; - finalCtx.drawImage(finalCanvas, 0, 0); - - finalCtx.filter = 'none'; - const cardWidth = width - padding; - const cardHeight = height - padding; - const cardX = padding / 2; - const cardY = padding / 2; - const radius = 20; - - finalCtx.fillStyle = 'rgba(255, 255, 255, 0.8)'; - finalCtx.beginPath(); - finalCtx.moveTo(cardX + radius, cardY); - finalCtx.lineTo(cardX + cardWidth - radius, cardY); - finalCtx.quadraticCurveTo(cardX + cardWidth, cardY, cardX + cardWidth, cardY + radius); - finalCtx.lineTo(cardX + cardWidth, cardY + cardHeight - radius); - finalCtx.quadraticCurveTo(cardX + cardWidth, cardY + cardHeight, cardX + cardWidth - radius, cardY + cardHeight); - finalCtx.lineTo(cardX + radius, cardY + cardHeight); - finalCtx.quadraticCurveTo(cardX, cardY + cardHeight, cardX, cardY + cardHeight - radius); - finalCtx.lineTo(cardX, cardY + radius); - finalCtx.quadraticCurveTo(cardX, cardY, cardX + radius, cardY); - finalCtx.closePath(); - finalCtx.fill(); - - // 绘制 JSON 内容 - finalCtx.fillStyle = 'black'; - let textY = cardY + 40; - - for (const line of lines) { - let x = cardX + 20; - for (const char of line) { - const isChinese = /[\u4e00-\u9fa5]/.test(char); - finalCtx.font = isChinese ? '20px "Aa偷吃可爱长大的"' : '20px "JetBrains Mono"'; - finalCtx.fillText(char, x, textY); - x += finalCtx.measureText(char).width; - } - textY += 30; - } - - // 保存图像 - const buffer = finalCanvas.toBuffer('image/png'); - return "base64://" + buffer.toString('base64'); -} - NCoreInitShell(); \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts index 28fe0ffd..d25aa5ac 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -9,7 +9,9 @@ const external = [ 'ws', 'express', '@ffmpeg.wasm/core-mt', - '@napi-rs/canvas' + '@napi-rs/canvas', + '@node-rs/jieba', + '@node-rs/jieba/dict.js', ]; const nodeModules = [...builtinModules, builtinModules.map((m) => `node:${m}`)].flat();