feat: 夜猫分析模块

This commit is contained in:
digua
2025-11-28 18:05:16 +08:00
parent 72605a6ef4
commit e2dcd4080f
7 changed files with 668 additions and 13 deletions
+354
View File
@@ -15,6 +15,12 @@ import type {
ChainLengthDistribution,
HotRepeatContent,
CatchphraseAnalysis,
NightOwlAnalysis,
NightOwlRankItem,
NightOwlTitle,
TimeRankItem,
ConsecutiveNightRecord,
NightOwlChampion,
} from '../../../src/types/chat'
import { openDatabase } from './core'
@@ -658,3 +664,351 @@ export function getCatchphraseAnalysis(sessionId: string, filter?: TimeFilter):
db.close()
}
}
/**
* 根据深夜发言数获取称号
*/
function getNightOwlTitleByCount(count: number): NightOwlTitle {
if (count === 0) return '养生达人'
if (count <= 20) return '偶尔失眠'
if (count <= 50) return '夜猫子'
if (count <= 100) return '秃头预备役'
if (count <= 200) return '修仙练习生'
if (count <= 500) return '守夜冠军'
return '不睡觉の神'
}
/**
* 将时间戳转换为"调整后的日期"(以凌晨5点为界)
* 05:00 之前的消息算作前一天
*/
function getAdjustedDate(ts: number): string {
const date = new Date(ts * 1000)
const hour = date.getHours()
// 如果是凌晨 0-4 点,算作前一天
if (hour < 5) {
date.setDate(date.getDate() - 1)
}
return date.toISOString().split('T')[0]
}
/**
* 格式化分钟数为 HH:MM
*/
function formatMinutes(minutes: number): string {
const h = Math.floor(minutes / 60)
const m = Math.round(minutes % 60)
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`
}
/**
* 获取夜猫分析数据
* 深夜时段定义:23:00-05:00
* 一天定义:05:00 ~ 次日 04:59
*/
export function getNightOwlAnalysis(sessionId: string, filter?: TimeFilter): NightOwlAnalysis {
const db = openDatabase(sessionId)
const emptyResult: NightOwlAnalysis = {
nightOwlRank: [],
lastSpeakerRank: [],
firstSpeakerRank: [],
consecutiveRecords: [],
champions: [],
totalDays: 0,
}
if (!db) return emptyResult
try {
const { clause, params } = buildTimeFilter(filter)
const clauseWithSystem = buildSystemMessageFilter(clause)
// 1. 获取所有消息(用于多种分析)
const messages = db
.prepare(
`
SELECT
msg.id,
msg.sender_id as senderId,
msg.ts,
m.platform_id as platformId,
m.name
FROM message msg
JOIN member m ON msg.sender_id = m.id
${clauseWithSystem}
ORDER BY msg.ts ASC
`
)
.all(...params) as Array<{
id: number
senderId: number
ts: number
platformId: string
name: string
}>
if (messages.length === 0) return emptyResult
// 成员信息映射
const memberInfo = new Map<number, { platformId: string; name: string }>()
// ========== 分析 1: 修仙排行榜 ==========
const nightStats = new Map<
number,
{
total: number
h23: number
h0: number
h1: number
h2: number
h3to4: number
totalMessages: number
}
>()
// ========== 分析 2 & 3: 最晚/最早发言 ==========
// 按调整后的日期分组消息
const dailyMessages = new Map<
string,
Array<{ senderId: number; ts: number; hour: number; minute: number }>
>()
// ========== 分析 4: 连续修仙天数 ==========
const memberNightDays = new Map<number, Set<string>>() // 成员 -> 有深夜发言的日期集合
for (const msg of messages) {
// 记录成员信息
if (!memberInfo.has(msg.senderId)) {
memberInfo.set(msg.senderId, { platformId: msg.platformId, name: msg.name })
}
const date = new Date(msg.ts * 1000)
const hour = date.getHours()
const minute = date.getMinutes()
const adjustedDate = getAdjustedDate(msg.ts)
// 初始化成员夜猫统计
if (!nightStats.has(msg.senderId)) {
nightStats.set(msg.senderId, { total: 0, h23: 0, h0: 0, h1: 0, h2: 0, h3to4: 0, totalMessages: 0 })
}
const stats = nightStats.get(msg.senderId)!
stats.totalMessages++
// 统计深夜发言 (23:00-05:00)
if (hour === 23) {
stats.h23++
stats.total++
} else if (hour === 0) {
stats.h0++
stats.total++
} else if (hour === 1) {
stats.h1++
stats.total++
} else if (hour === 2) {
stats.h2++
stats.total++
} else if (hour >= 3 && hour < 5) {
stats.h3to4++
stats.total++
}
// 记录深夜发言的日期(用于连续天数统计)
if (hour >= 23 || hour < 5) {
if (!memberNightDays.has(msg.senderId)) {
memberNightDays.set(msg.senderId, new Set())
}
memberNightDays.get(msg.senderId)!.add(adjustedDate)
}
// 按日期分组消息(用于最晚/最早发言统计)
if (!dailyMessages.has(adjustedDate)) {
dailyMessages.set(adjustedDate, [])
}
dailyMessages.get(adjustedDate)!.push({ senderId: msg.senderId, ts: msg.ts, hour, minute })
}
const totalDays = dailyMessages.size
// ========== 构建修仙排行榜 ==========
const nightOwlRank: NightOwlRankItem[] = []
for (const [memberId, stats] of nightStats.entries()) {
if (stats.total === 0) continue
const info = memberInfo.get(memberId)!
nightOwlRank.push({
memberId,
platformId: info.platformId,
name: info.name,
totalNightMessages: stats.total,
title: getNightOwlTitleByCount(stats.total),
hourlyBreakdown: {
h23: stats.h23,
h0: stats.h0,
h1: stats.h1,
h2: stats.h2,
h3to4: stats.h3to4,
},
percentage: stats.totalMessages > 0 ? Math.round((stats.total / stats.totalMessages) * 10000) / 100 : 0,
})
}
nightOwlRank.sort((a, b) => b.totalNightMessages - a.totalNightMessages)
// ========== 构建最晚/最早发言排行 ==========
const lastSpeakerStats = new Map<number, { count: number; times: number[] }>()
const firstSpeakerStats = new Map<number, { count: number; times: number[] }>()
for (const [, dayMessages] of dailyMessages.entries()) {
if (dayMessages.length === 0) continue
// 找到当天最后发言的人
const lastMsg = dayMessages[dayMessages.length - 1]
if (!lastSpeakerStats.has(lastMsg.senderId)) {
lastSpeakerStats.set(lastMsg.senderId, { count: 0, times: [] })
}
const lastStats = lastSpeakerStats.get(lastMsg.senderId)!
lastStats.count++
lastStats.times.push(lastMsg.hour * 60 + lastMsg.minute)
// 找到当天最早发言的人
const firstMsg = dayMessages[0]
if (!firstSpeakerStats.has(firstMsg.senderId)) {
firstSpeakerStats.set(firstMsg.senderId, { count: 0, times: [] })
}
const firstStats = firstSpeakerStats.get(firstMsg.senderId)!
firstStats.count++
firstStats.times.push(firstMsg.hour * 60 + firstMsg.minute)
}
// 构建最晚发言排行
const lastSpeakerRank: TimeRankItem[] = []
for (const [memberId, stats] of lastSpeakerStats.entries()) {
const info = memberInfo.get(memberId)!
const avgMinutes = stats.times.reduce((a, b) => a + b, 0) / stats.times.length
const maxMinutes = Math.max(...stats.times)
lastSpeakerRank.push({
memberId,
platformId: info.platformId,
name: info.name,
count: stats.count,
avgTime: formatMinutes(avgMinutes),
extremeTime: formatMinutes(maxMinutes),
percentage: totalDays > 0 ? Math.round((stats.count / totalDays) * 10000) / 100 : 0,
})
}
lastSpeakerRank.sort((a, b) => b.count - a.count)
// 构建最早发言排行
const firstSpeakerRank: TimeRankItem[] = []
for (const [memberId, stats] of firstSpeakerStats.entries()) {
const info = memberInfo.get(memberId)!
const avgMinutes = stats.times.reduce((a, b) => a + b, 0) / stats.times.length
const minMinutes = Math.min(...stats.times)
firstSpeakerRank.push({
memberId,
platformId: info.platformId,
name: info.name,
count: stats.count,
avgTime: formatMinutes(avgMinutes),
extremeTime: formatMinutes(minMinutes),
percentage: totalDays > 0 ? Math.round((stats.count / totalDays) * 10000) / 100 : 0,
})
}
firstSpeakerRank.sort((a, b) => b.count - a.count)
// ========== 构建连续修仙天数记录 ==========
const consecutiveRecords: ConsecutiveNightRecord[] = []
for (const [memberId, nightDaysSet] of memberNightDays.entries()) {
if (nightDaysSet.size === 0) continue
const info = memberInfo.get(memberId)!
const sortedDays = Array.from(nightDaysSet).sort()
let maxStreak = 1
let currentStreak = 1
for (let i = 1; i < sortedDays.length; i++) {
const prevDate = new Date(sortedDays[i - 1])
const currDate = new Date(sortedDays[i])
const diffDays = (currDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24)
if (diffDays === 1) {
currentStreak++
maxStreak = Math.max(maxStreak, currentStreak)
} else {
currentStreak = 1
}
}
// 检查当前是否还在连续(最后一天是否是最近的)
const lastDay = sortedDays[sortedDays.length - 1]
const today = new Date().toISOString().split('T')[0]
const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0]
const isCurrentStreak = lastDay === today || lastDay === yesterday
consecutiveRecords.push({
memberId,
platformId: info.platformId,
name: info.name,
maxConsecutiveDays: maxStreak,
currentStreak: isCurrentStreak ? currentStreak : 0,
})
}
consecutiveRecords.sort((a, b) => b.maxConsecutiveDays - a.maxConsecutiveDays)
// ========== 构建修仙王者(综合排名) ==========
// 综合得分 = 深夜发言数 × 1 + 最晚下班次数 × 10 + 连续修仙天数 × 20
const championScores = new Map<number, { nightMessages: number; lastSpeakerCount: number; consecutiveDays: number }>()
for (const item of nightOwlRank) {
if (!championScores.has(item.memberId)) {
championScores.set(item.memberId, { nightMessages: 0, lastSpeakerCount: 0, consecutiveDays: 0 })
}
championScores.get(item.memberId)!.nightMessages = item.totalNightMessages
}
for (const item of lastSpeakerRank) {
if (!championScores.has(item.memberId)) {
championScores.set(item.memberId, { nightMessages: 0, lastSpeakerCount: 0, consecutiveDays: 0 })
}
championScores.get(item.memberId)!.lastSpeakerCount = item.count
}
for (const item of consecutiveRecords) {
if (!championScores.has(item.memberId)) {
championScores.set(item.memberId, { nightMessages: 0, lastSpeakerCount: 0, consecutiveDays: 0 })
}
championScores.get(item.memberId)!.consecutiveDays = item.maxConsecutiveDays
}
const champions: NightOwlChampion[] = []
for (const [memberId, scores] of championScores.entries()) {
const info = memberInfo.get(memberId)!
const score = scores.nightMessages * 1 + scores.lastSpeakerCount * 10 + scores.consecutiveDays * 20
if (score > 0) {
champions.push({
memberId,
platformId: info.platformId,
name: info.name,
score,
nightMessages: scores.nightMessages,
lastSpeakerCount: scores.lastSpeakerCount,
consecutiveDays: scores.consecutiveDays,
})
}
}
champions.sort((a, b) => b.score - a.score)
return {
nightOwlRank,
lastSpeakerRank,
firstSpeakerRank,
consecutiveRecords,
champions,
totalDays,
}
} finally {
db.close()
}
}
+1
View File
@@ -18,6 +18,7 @@ export {
getMemberNameHistory,
getRepeatAnalysis,
getCatchphraseAnalysis,
getNightOwlAnalysis,
} from './analysis'
// 类型导出
+22
View File
@@ -405,6 +405,28 @@ const mainIpcMain = (win: BrowserWindow) => {
}
}
)
/**
* 获取夜猫分析数据
*/
ipcMain.handle(
'chat:getNightOwlAnalysis',
async (_, sessionId: string, filter?: { startTs?: number; endTs?: number }) => {
try {
return database.getNightOwlAnalysis(sessionId, filter)
} catch (error) {
console.error('获取夜猫分析失败:', error)
return {
nightOwlRank: [],
lastSpeakerRank: [],
firstSpeakerRank: [],
consecutiveRecords: [],
champions: [],
totalDays: 0,
}
}
}
)
}
export default mainIpcMain
+2
View File
@@ -10,6 +10,7 @@ import type {
ImportProgress,
RepeatAnalysis,
CatchphraseAnalysis,
NightOwlAnalysis,
} from '../../src/types/chat'
interface TimeFilter {
@@ -39,6 +40,7 @@ interface ChatApi {
onImportProgress: (callback: (progress: ImportProgress) => void) => () => void
getRepeatAnalysis: (sessionId: string, filter?: TimeFilter) => Promise<RepeatAnalysis>
getCatchphraseAnalysis: (sessionId: string, filter?: TimeFilter) => Promise<CatchphraseAnalysis>
getNightOwlAnalysis: (sessionId: string, filter?: TimeFilter) => Promise<NightOwlAnalysis>
}
interface Api {
+11
View File
@@ -11,6 +11,7 @@ import type {
ImportProgress,
RepeatAnalysis,
CatchphraseAnalysis,
NightOwlAnalysis,
} from '../../src/types/chat'
// Custom APIs for renderer
@@ -179,6 +180,16 @@ const chatApi = {
): Promise<CatchphraseAnalysis> => {
return ipcRenderer.invoke('chat:getCatchphraseAnalysis', sessionId, filter)
},
/**
* 获取夜猫分析数据
*/
getNightOwlAnalysis: (
sessionId: string,
filter?: { startTs?: number; endTs?: number }
): Promise<NightOwlAnalysis> => {
return ipcRenderer.invoke('chat:getNightOwlAnalysis', sessionId, filter)
},
}
// Use `contextBridge` APIs to expose Electron APIs to