This commit is contained in:
手瓜一十雪
2025-03-25 16:53:48 +08:00
parent b9a9438bf0
commit 2754165ae5
8 changed files with 1659 additions and 92 deletions

View File

@@ -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"
}
}

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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<string, number>();
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<number, number>(); // 0-6 对应周日到周六
const hourCount = new Map<number, number>(); // 0-23 小时
const timeSlotCount = new Map<string, number>(); // 早上/下午/晚上等时段
// 定义时间段
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<string, number>();
// 收集所有直接可用的 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<string, {
count: number,
url: string,
senders: Map<string, number> // 记录每个用户发送次数
}>();
// 处理所有表情包和图片
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<string, {
count: number,
url: string,
lastUsed: number
}>();
// 处理所有消息中的表情
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);
}
}
};

952
src/plugin/wordcloud.ts Normal file
View File

@@ -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<string> {
// 空数组检查
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');
}

77
src/shell/drawJson.ts Normal file
View File

@@ -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');
}

View File

@@ -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();

View File

@@ -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();