mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 19:47:08 +08:00
feat: 导出功能
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
import type { Message } from '../shared/types'
|
||||
|
||||
export const exportStyles = `:root{color-scheme:light;--page:#edf2f0;--panel:#fff;--text:#1d2a25;--muted:#68766f;--border:#d8e2dc;--mine:#d9f0e2;--accent:#176b57}*{box-sizing:border-box}body{margin:0;background:var(--page);color:var(--text);font:14px system-ui,-apple-system,"PingFang SC","Microsoft YaHei",sans-serif}.page{max-width:1240px;height:100vh;margin:auto;padding:22px 28px;display:flex;flex-direction:column}.toolbar{display:flex;align-items:center;justify-content:space-between;gap:20px;background:var(--panel);border:1px solid var(--border);border-radius:18px;padding:18px 24px;box-shadow:0 8px 24px #29483b12}.title{font-size:18px;font-weight:750}.meta{color:var(--muted);margin-left:12px;font-size:13px}.controls{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:0}.controls input,.controls button{border:1px solid var(--border);border-radius:10px;padding:9px 12px;background:#fff;font:inherit}.controls input[type=search]{width:260px}.controls input[type=datetime-local],.controls #jump{display:none}.controls button{background:var(--accent);border-color:var(--accent);color:#fff;cursor:pointer}.count{margin-left:8px;color:var(--muted);font-size:13px}.scroll{margin-top:18px;overflow:auto;flex:1;padding:10px 6px 30px}.message{display:flex;flex-direction:column;gap:6px;max-width:820px;margin:0 0 22px}.message.hidden{display:none}.message.sent{align-items:flex-end;margin-left:auto}.time{color:var(--muted);font-size:11px;margin:0 12px}.row{display:flex;gap:12px;align-items:flex-end}.sent .row{flex-direction:row-reverse}.avatar{width:38px;height:38px;flex:0 0 auto;border-radius:50%;overflow:hidden;background:#dcebe4;display:grid;place-items:center}.avatar img{width:100%;height:100%;object-fit:cover}.bubble{max-width:min(78%,760px);padding:13px 15px;border:1px solid var(--border);border-radius:10px 18px 18px 18px;background:#fff;box-shadow:0 4px 12px #29483b0d}.sent .bubble{background:var(--mine);border-color:#c7e6d4;border-radius:18px 10px 18px 18px}.sender{color:var(--muted);font-size:12px;margin-bottom:5px}.content{line-height:1.7;word-break:break-word;white-space:pre-wrap}.audio-wrap{width:260px;min-width:260px}.audio{display:block;width:260px;height:38px}.quote-reference{margin-top:10px;padding:8px 11px;border-left:3px solid #8eb4a3;background:#f1f6f3;color:var(--muted);display:grid;gap:3px}.quote-reference strong{font-weight:650;color:var(--text)}.quote-reference span{white-space:pre-wrap}.media-image{display:block;max-width:100%;max-height:360px;border-radius:12px;object-fit:contain;background:#eef2f5;cursor:zoom-in}.lightbox{position:fixed;inset:0;display:none;place-items:center;background:#14231ddd;z-index:10;padding:24px;overflow:auto}.lightbox.open{display:grid}.lightbox img{width:min(86vw,980px);max-height:88vh;object-fit:contain;cursor:zoom-in;transform:scale(var(--zoom,1));transform-origin:center;transition:transform .12s ease}`
|
||||
const safe = (value: unknown): string =>
|
||||
String(value ?? '').replace(
|
||||
/[&<>"']/g,
|
||||
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] || c
|
||||
)
|
||||
|
||||
export function renderExportPage(name: string, messages: Message[]): string {
|
||||
const body = messages
|
||||
.map((m) => {
|
||||
const avatar = m.img
|
||||
? `<img src="${safe(m.img)}" alt="">`
|
||||
: safe((m.name || (m.isSender ? '我' : '友')).slice(0, 1))
|
||||
const audio = m.voiceDataUrl
|
||||
? `<div class="audio-wrap"><audio class="audio" controls preload="metadata" src="${m.voiceDataUrl}"></audio></div>`
|
||||
: ''
|
||||
const quote =
|
||||
m.contentData?.type === 'quote'
|
||||
? `<div class="quote-reference"><strong>${safe(m.contentData.quotedSender || '引用消息')}</strong><span>${safe(m.contentData.quotedContent || '[引用消息]')}</span></div>`
|
||||
: ''
|
||||
const media =
|
||||
m.exportMediaUrl && m.exportMediaType === 'image'
|
||||
? `<img class="media-image" src="${safe(m.exportMediaUrl)}" alt="图片">`
|
||||
: m.exportMediaUrl && m.exportMediaType === 'video'
|
||||
? `<video class="media-image" controls src="${safe(m.exportMediaUrl)}"></video>`
|
||||
: m.exportMediaUrl && m.exportMediaType === 'sticker'
|
||||
? `<img class="media-image" src="${safe(m.exportMediaUrl)}" alt="表情包">`
|
||||
: ''
|
||||
const avatarMarkup =
|
||||
m.exportShowAvatar === false
|
||||
? ''
|
||||
: `<div class="avatar">${m.exportAvatarUrl ? `<img src="${safe(m.exportAvatarUrl)}" alt="">` : avatar}</div>`
|
||||
const text = m.content || (m.contentData?.type === 'quote' ? m.contentData.title : '')
|
||||
return `<article class="message${m.isSender ? ' sent' : ''}" data-time="${m.createTime || 0}" data-search="${safe(`${m.name || ''} ${m.content || ''} ${m.type}`.toLowerCase())}"><div class="time">${safe(m.datetime)}</div><div class="row">${avatarMarkup}<div class="bubble"><div class="sender">${safe(m.name || (m.isSender ? '我' : '联系人'))}</div>${media}${audio}${quote}<div class="content">${safe(text || (!media && !audio && !quote ? `[${m.type}]` : ''))}</div></div></div></article>`
|
||||
})
|
||||
.join('')
|
||||
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${safe(name)} - 聊天记录</title><style>${exportStyles}</style></head><body><main class="page"><header class="toolbar"><div><span class="title">${safe(name)}</span><span class="meta">${messages.length.toLocaleString()} 条消息</span></div><div class="controls"><input id="query" type="search" placeholder="搜索消息..."><input id="point" type="datetime-local"><button id="jump">跳转</button><span class="count" id="count"></span></div></header><section class="scroll" id="messages">${body}</section></main><div class="lightbox" id="lightbox"><img id="lightbox-image" alt="预览"></div><script>(()=>{const all=[...document.querySelectorAll('.message')],q=document.querySelector('#query'),d=document.querySelector('#point'),c=document.querySelector('#count'),box=document.querySelector('#lightbox'),preview=document.querySelector('#lightbox-image');let zoom=1;const updateZoom=()=>preview.style.setProperty('--zoom',zoom);const update=()=>{const term=q.value.trim().toLowerCase(),at=d.value?new Date(d.value).getTime()/1000:0;let n=0;all.forEach(x=>{const ok=(!term||x.dataset.search.includes(term))&&(!at||Number(x.dataset.time)>=at);x.classList.toggle('hidden',!ok);if(ok)n++});c.textContent='共 '+n+' 条'};q.addEventListener('input',update);d.addEventListener('change',update);document.querySelector('#jump').onclick=()=>{const at=d.value?new Date(d.value).getTime()/1000:0;all.find(x=>Number(x.dataset.time)>=at)?.scrollIntoView({behavior:'smooth',block:'center'})};document.querySelectorAll('.media-image').forEach(image=>image.addEventListener('click',()=>{if(image.tagName==='IMG'){preview.src=image.src;zoom=1;updateZoom();box.classList.add('open')}}));preview.addEventListener('wheel',event=>{event.preventDefault();zoom=Math.min(5,Math.max(.5,zoom+(event.deltaY<0?.2:-.2)));updateZoom()},{passive:false});preview.addEventListener('dblclick',()=>{zoom=1;updateZoom()});box.addEventListener('click',event=>{if(event.target===box)box.classList.remove('open')});update()})()</script></body></html>`
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
import { app, BrowserWindow, shell } from 'electron'
|
||||
import { promises as fs } from 'fs'
|
||||
import { extname, join } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import * as chat from './services/chat-service'
|
||||
import type {
|
||||
ExportJobProgress,
|
||||
ExportMessageKind,
|
||||
ExportRequest,
|
||||
ExportResult
|
||||
} from '../shared/export'
|
||||
import type { Message } from '../shared/types'
|
||||
import { VoiceService } from './voice-service'
|
||||
import { renderExportPage } from './export-html-template'
|
||||
import { ImageDecryptService } from './image-decrypt-service'
|
||||
import { ImageKeyConfigService } from './services/image-key-config-service'
|
||||
import { VideoAssetService } from './video-asset-service'
|
||||
import { StickerService } from './sticker-service'
|
||||
|
||||
const jobs = new Set<string>()
|
||||
const safeFilePart = (value: string): string =>
|
||||
value.replace(/[\\/:*?"<>|]/g, '_').trim() || '聊天档案'
|
||||
const exportStamp = (): string => {
|
||||
const date = new Date()
|
||||
const pad = (value: number): string => String(value).padStart(2, '0')
|
||||
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}_${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`
|
||||
}
|
||||
const imageKeys = new ImageKeyConfigService()
|
||||
function decodeDataUrl(data: string): { extension: string; buffer: Buffer } | null {
|
||||
const match = /^data:([^;]+);base64,(.+)$/s.exec(data)
|
||||
if (!match) return null
|
||||
return {
|
||||
extension: match[1].split('/')[1] === 'jpeg' ? 'jpg' : match[1].split('/')[1],
|
||||
buffer: Buffer.from(match[2], 'base64')
|
||||
}
|
||||
}
|
||||
const normalizeAssetExtension = (value: string): string => {
|
||||
const extension = value.toLowerCase().replace(/^\./, '')
|
||||
return /^(png|jpg|jpeg|webp|gif)$/.test(extension)
|
||||
? extension === 'jpeg'
|
||||
? 'jpg'
|
||||
: extension
|
||||
: 'jpg'
|
||||
}
|
||||
const detectAssetExtension = (buffer: Buffer): string | null => {
|
||||
if (buffer.subarray(0, 3).toString('ascii') === 'GIF') return 'gif'
|
||||
if (buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])))
|
||||
return 'png'
|
||||
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return 'jpg'
|
||||
if (
|
||||
buffer.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
||||
buffer.subarray(8, 12).toString('ascii') === 'WEBP'
|
||||
)
|
||||
return 'webp'
|
||||
return null
|
||||
}
|
||||
async function readAvatarAsset(
|
||||
source: string
|
||||
): Promise<{ extension: string; buffer: Buffer } | null> {
|
||||
const decoded = decodeDataUrl(source)
|
||||
if (decoded) return { ...decoded, extension: normalizeAssetExtension(decoded.extension) }
|
||||
|
||||
try {
|
||||
if (/^https?:\/\//i.test(source)) {
|
||||
const response = await fetch(source)
|
||||
if (!response.ok) return null
|
||||
const contentType = response.headers.get('content-type')?.split(';')[0].split('/')[1]
|
||||
const extension = normalizeAssetExtension(contentType || extname(new URL(source).pathname))
|
||||
const buffer = Buffer.from(await response.arrayBuffer())
|
||||
return { extension: detectAssetExtension(buffer) || extension, buffer }
|
||||
}
|
||||
const path = source.startsWith('file://') ? fileURLToPath(source) : source
|
||||
const buffer = await fs.readFile(path)
|
||||
return {
|
||||
extension: detectAssetExtension(buffer) || normalizeAssetExtension(extname(path)),
|
||||
buffer
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
const kindOf = (message: Message): ExportMessageKind => {
|
||||
const type = message.contentData?.type
|
||||
if (
|
||||
type === 'image' ||
|
||||
type === 'video' ||
|
||||
type === 'voice' ||
|
||||
type === 'sticker' ||
|
||||
type === 'share' ||
|
||||
type === 'location' ||
|
||||
type === 'system'
|
||||
)
|
||||
return type
|
||||
if (message.type === '图片') return 'image'
|
||||
if (message.type === '视频') return 'video'
|
||||
if (message.type === '语音') return 'voice'
|
||||
if (message.type === '表情包') return 'sticker'
|
||||
return 'text'
|
||||
}
|
||||
const csv = (value: unknown): string => `"${String(value ?? '').replace(/"/g, '""')}"`
|
||||
|
||||
function render(format: ExportRequest['format'], messages: Message[], name: string): string {
|
||||
if (format === 'html') return renderExportPage(name, messages)
|
||||
if (format === 'json')
|
||||
return JSON.stringify({ name, exportedAt: new Date().toISOString(), messages }, null, 2)
|
||||
if (format === 'markdown')
|
||||
return `# ${name}\n\n${messages.map((m) => `**${m.name || (m.isSender ? '我' : '联系人')}** · ${m.datetime}\n\n${m.content || `[${m.type}]`}\n`).join('\n')}`
|
||||
return [
|
||||
'时间,发送者,类型,内容',
|
||||
...messages.map((m) =>
|
||||
[m.datetime, m.name || (m.isSender ? '我' : '联系人'), m.type, m.content].map(csv).join(',')
|
||||
)
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export async function runExport(request: ExportRequest, win: BrowserWindow): Promise<ExportResult> {
|
||||
jobs.add(request.jobId)
|
||||
const send = (p: ExportJobProgress): void => {
|
||||
if (!win.isDestroyed()) win.webContents.send('export:progress', p)
|
||||
}
|
||||
try {
|
||||
send({ jobId: request.jobId, phase: 'reading', processed: 0, total: 100, percent: 0 })
|
||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||
const messages = chat
|
||||
.listMessages(request.userMd5, request.startTime, request.endTime)
|
||||
.filter((m) => request.kinds.includes(kindOf(m)))
|
||||
for (const message of messages) {
|
||||
message.exportShowAvatar = request.includeAvatars !== false
|
||||
const mappedName = message.senderId ? request.nameMap?.[message.senderId] : undefined
|
||||
if (mappedName) message.name = mappedName
|
||||
}
|
||||
send({ jobId: request.jobId, phase: 'reading', processed: 10, total: 100, percent: 10 })
|
||||
if (!jobs.has(request.jobId)) {
|
||||
send({ jobId: request.jobId, phase: 'cancelled', processed: 0, percent: 10 })
|
||||
return { success: false, error: '已取消' }
|
||||
}
|
||||
send({
|
||||
jobId: request.jobId,
|
||||
phase: 'writing',
|
||||
processed: 0,
|
||||
total: messages.length,
|
||||
percent: 15
|
||||
})
|
||||
const root = join(app.getPath('documents'), 'WechatExplorer', '导出')
|
||||
await fs.mkdir(root, { recursive: true })
|
||||
const ext = request.format === 'markdown' ? 'md' : request.format
|
||||
const outputFolder = `${safeFilePart(request.outputName)}_${exportStamp()}`
|
||||
const outputDir = join(root, outputFolder)
|
||||
const outputPath =
|
||||
request.format === 'html'
|
||||
? join(outputDir, 'index.html')
|
||||
: join(root, `${outputFolder}.${ext}`)
|
||||
if (request.format === 'html') {
|
||||
await fs.mkdir(join(outputDir, 'voices'), { recursive: true })
|
||||
await fs.mkdir(join(outputDir, 'media'), { recursive: true })
|
||||
await fs.mkdir(join(outputDir, 'avatars'), { recursive: true })
|
||||
const client = chat.getChatDb()?.getWcdb4Client()
|
||||
const avatarUsernames = Array.from(
|
||||
new Set(
|
||||
messages
|
||||
.map((message) => message.senderId)
|
||||
.filter((value): value is string => Boolean(value))
|
||||
)
|
||||
)
|
||||
const avatarMap =
|
||||
request.includeAvatars === false
|
||||
? {}
|
||||
: { ...chat.getContactAvatars(avatarUsernames), ...(request.avatarUrls || {}) }
|
||||
const imageConfig = imageKeys.getConfig()
|
||||
const imageService =
|
||||
client && imageConfig.aesKey
|
||||
? new ImageDecryptService(imageConfig.xorKey || '0x40', imageConfig.aesKey, client)
|
||||
: null
|
||||
const videoService = client ? new VideoAssetService(client) : null
|
||||
const stickerService = client ? new StickerService(client) : null
|
||||
const exportedAvatars = new Map<string, string>()
|
||||
const voiceService =
|
||||
request.includeMedia && chat.getChatDb()
|
||||
? new VoiceService(chat.getChatDb()!.getWcdb4Client())
|
||||
: null
|
||||
if (voiceService) {
|
||||
for (const [index, message] of messages.entries()) {
|
||||
if (
|
||||
kindOf(message) !== 'voice' ||
|
||||
!message.sessionId ||
|
||||
!message.localId ||
|
||||
!message.createTime
|
||||
)
|
||||
continue
|
||||
const voice = await voiceService.resolveVoice(
|
||||
message.sessionId,
|
||||
message.localId,
|
||||
message.createTime,
|
||||
message.serverId
|
||||
)
|
||||
if (!voice.success || !voice.data) continue
|
||||
const voiceName = `voice_${index + 1}_${message.localId}.wav`
|
||||
const audioBuffer = Buffer.from(voice.data, 'base64')
|
||||
await fs.writeFile(join(outputDir, 'voices', voiceName), audioBuffer)
|
||||
message.voiceDataUrl = `voices/${voiceName}`
|
||||
message.voiceDuration = Math.max(1, Math.round(audioBuffer.length / (24000 * 2)))
|
||||
}
|
||||
}
|
||||
for (const [index, message] of messages.entries()) {
|
||||
message.exportShowAvatar = request.includeAvatars !== false
|
||||
const avatar = (message.senderId ? avatarMap[message.senderId] : undefined) || message.img
|
||||
const resolvedAvatar = avatar ? await readAvatarAsset(avatar) : null
|
||||
const avatarBuffer = resolvedAvatar?.buffer || null
|
||||
const avatarExtension = resolvedAvatar?.extension || 'jpg'
|
||||
if (avatarBuffer) {
|
||||
const avatarKey = message.senderId || `message_${index + 1}`
|
||||
let avatarName = exportedAvatars.get(avatarKey)
|
||||
if (!avatarName) {
|
||||
avatarName = `avatar_${exportedAvatars.size + 1}.${avatarExtension}`
|
||||
await fs.writeFile(join(outputDir, 'avatars', avatarName), avatarBuffer)
|
||||
exportedAvatars.set(avatarKey, avatarName)
|
||||
}
|
||||
message.exportAvatarUrl = `avatars/${avatarName}`
|
||||
}
|
||||
if (!request.includeMedia || !message.contentData) {
|
||||
send({
|
||||
jobId: request.jobId,
|
||||
phase: 'writing',
|
||||
processed: index + 1,
|
||||
total: messages.length,
|
||||
percent: 15 + Math.round(((index + 1) / Math.max(messages.length, 1)) * 75)
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (message.contentData.type === 'image' && imageService) {
|
||||
const file = imageService.findImageFile(
|
||||
message.contentData.md5,
|
||||
message.contentData.datName,
|
||||
{ allowThumbnail: true }
|
||||
)
|
||||
const decrypted = file ? imageService.decryptImageToBase64WithFallback(file, true) : null
|
||||
const decoded = decrypted ? decodeDataUrl(decrypted.data) : null
|
||||
if (decoded) {
|
||||
const name = `image_${index + 1}.${decoded.extension}`
|
||||
await fs.writeFile(join(outputDir, 'media', name), decoded.buffer)
|
||||
message.exportMediaUrl = `media/${name}`
|
||||
message.exportMediaType = 'image'
|
||||
}
|
||||
} else if (message.contentData.type === 'video' && videoService) {
|
||||
const hashes = [
|
||||
message.contentData.md5,
|
||||
message.contentData.newMd5,
|
||||
message.contentData.rawMd5
|
||||
].filter((value): value is string => Boolean(value))
|
||||
const resolved = videoService.resolve(hashes)
|
||||
const token = resolved.url?.split('/').pop()
|
||||
const source = token ? videoService.pathForToken(token) : undefined
|
||||
if (source) {
|
||||
const name = `video_${index + 1}.mp4`
|
||||
await fs.copyFile(source, join(outputDir, 'media', name))
|
||||
message.exportMediaUrl = `media/${name}`
|
||||
message.exportMediaType = 'video'
|
||||
}
|
||||
} else if (message.contentData.type === 'sticker' && stickerService) {
|
||||
const stickerSource = message.contentData.url || message.contentData.thumbUrl
|
||||
const result = await stickerService.resolveSticker(stickerSource, message.contentData.md5)
|
||||
const decoded = result.data
|
||||
? decodeDataUrl(result.data)
|
||||
: stickerSource
|
||||
? await readAvatarAsset(stickerSource)
|
||||
: null
|
||||
if (decoded) {
|
||||
const name = `sticker_${index + 1}.${decoded.extension}`
|
||||
await fs.writeFile(join(outputDir, 'media', name), decoded.buffer)
|
||||
message.exportMediaUrl = `media/${name}`
|
||||
message.exportMediaType = 'sticker'
|
||||
}
|
||||
}
|
||||
send({
|
||||
jobId: request.jobId,
|
||||
phase: 'writing',
|
||||
processed: index + 1,
|
||||
total: messages.length,
|
||||
percent: 15 + Math.round(((index + 1) / Math.max(messages.length, 1)) * 75)
|
||||
})
|
||||
}
|
||||
} else {
|
||||
send({
|
||||
jobId: request.jobId,
|
||||
phase: 'writing',
|
||||
processed: messages.length,
|
||||
total: messages.length,
|
||||
percent: 90
|
||||
})
|
||||
}
|
||||
await fs.writeFile(outputPath, render(request.format, messages, request.name), 'utf8')
|
||||
send({
|
||||
jobId: request.jobId,
|
||||
phase: 'completed',
|
||||
processed: messages.length,
|
||||
total: messages.length,
|
||||
percent: 100,
|
||||
outputPath
|
||||
})
|
||||
return { success: true, outputPath, messageCount: messages.length }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
send({ jobId: request.jobId, phase: 'failed', processed: 0, error: message })
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
jobs.delete(request.jobId)
|
||||
}
|
||||
}
|
||||
export function cancelExport(jobId: string): void {
|
||||
jobs.delete(jobId)
|
||||
}
|
||||
export async function revealExport(path: string): Promise<void> {
|
||||
shell.showItemInFolder(path)
|
||||
}
|
||||
+30
-5
@@ -75,6 +75,8 @@ import { appLogger } from './app-logger'
|
||||
import type { AppLogEntry } from '../shared/app-log'
|
||||
import { configureRecallArchive, RecallArchiveMonitor } from './services/recall-archive-service'
|
||||
import { VideoAssetService } from './video-asset-service'
|
||||
import { cancelExport, revealExport, runExport } from './export-service'
|
||||
import type { ExportRequest } from '../shared/export'
|
||||
|
||||
// electron-vite can close the child's stdout/stderr after spawning Electron.
|
||||
// Plain console.error then throws EPIPE on a closed pipe and crashes the IPC
|
||||
@@ -105,7 +107,15 @@ protocol.registerSchemesAsPrivileged([
|
||||
|
||||
// WCDB's Windows runtime checks the host application name during wcdb_init.
|
||||
// Mirroring WeFlow's name unblocks the -1006 init failure on Windows.
|
||||
app.setName(process.platform === 'win32' ? 'WeFlow' : 'WechatExplorer')
|
||||
app.setName(
|
||||
process.platform === 'win32'
|
||||
? 'WeFlow'
|
||||
: process.env['WXE_USER_DATA']
|
||||
? 'WechatExplorer Dev'
|
||||
: 'WechatExplorer'
|
||||
)
|
||||
const isolatedUserData = process.env['WXE_USER_DATA']
|
||||
if (isolatedUserData) app.setPath('userData', isolatedUserData)
|
||||
let dbInitInFlight: Promise<{ success: boolean; monitoring?: boolean; error?: string }> | null =
|
||||
null
|
||||
const BUILD_MARK = 'wechat4-local-http-api-2026-07-03'
|
||||
@@ -419,10 +429,7 @@ app.whenReady().then(async () => {
|
||||
// 优先级:chat 真实识别到的根 → self.accountRoot → settings.imageKeyRoot → settings.dbRoot
|
||||
// 必须先看 chat.getCurrentAccountRoot(),否则 settings 缓存漂移会导致扫错目录。
|
||||
const accountRoot =
|
||||
chat.getCurrentAccountRoot() ||
|
||||
self?.accountRoot ||
|
||||
settings.imageKeyRoot ||
|
||||
settings.dbRoot
|
||||
chat.getCurrentAccountRoot() || self?.accountRoot || settings.imageKeyRoot || settings.dbRoot
|
||||
const wxid = self?.wxid
|
||||
const onStatus = (message: string): void => {
|
||||
if (!event.sender.isDestroyed()) event.sender.send('key:imageKeyStatus', { message })
|
||||
@@ -568,6 +575,24 @@ app.whenReady().then(async () => {
|
||||
return exportGroupReport(request)
|
||||
})
|
||||
|
||||
ipcMain.handle('export:start', async (event, request: ExportRequest) => {
|
||||
const window = BrowserWindow.fromWebContents(event.sender)
|
||||
if (!window) return { success: false, error: '窗口不可用' }
|
||||
return runExport(request, window)
|
||||
})
|
||||
ipcMain.handle('export:cancel', (_, jobId: string) => {
|
||||
cancelExport(jobId)
|
||||
return { success: true }
|
||||
})
|
||||
ipcMain.handle('export:reveal', async (_, path: string) => {
|
||||
try {
|
||||
await revealExport(path)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('report:listGenerated', async () => {
|
||||
return listGeneratedReports()
|
||||
})
|
||||
|
||||
@@ -49,6 +49,8 @@ type QuoteContent = {
|
||||
quotedContent?: string
|
||||
quotedSender?: string
|
||||
quotedType?: string
|
||||
quotedImageMd5?: string
|
||||
quotedImageDatName?: string
|
||||
}
|
||||
type SystemContent = {
|
||||
type: 'system'
|
||||
@@ -391,6 +393,10 @@ function parseLocationMessage(content: string): ParsedContent {
|
||||
|
||||
function parseShareMessage(content: string): ParsedContent {
|
||||
const appMsgType = extractAppMsgType(content)
|
||||
if (appMsgType === '47' || /<(?:emoji|sticker|emoticon)\b/i.test(content)) {
|
||||
const sticker = parseStickerMessage(content)
|
||||
if (sticker.type === 'sticker') return sticker
|
||||
}
|
||||
if (appMsgType === '57' || content.includes('<refermsg>')) {
|
||||
const quote = parseQuoteMessage(content)
|
||||
const title = extractXmlValue(content, 'title') || undefined
|
||||
@@ -400,7 +406,9 @@ function parseShareMessage(content: string): ParsedContent {
|
||||
content: title,
|
||||
quotedContent: quote.content || '[引用消息]',
|
||||
quotedSender: quote.sender,
|
||||
quotedType: quote.type
|
||||
quotedType: quote.type,
|
||||
quotedImageMd5: quote.imageMd5,
|
||||
quotedImageDatName: quote.imageDatName
|
||||
}
|
||||
}
|
||||
|
||||
@@ -417,7 +425,13 @@ function parseShareMessage(content: string): ParsedContent {
|
||||
return { type: 'share', title, des, url, appname, typeVal }
|
||||
}
|
||||
|
||||
function parseQuoteMessage(content: string): { content?: string; sender?: string; type?: string } {
|
||||
function parseQuoteMessage(content: string): {
|
||||
content?: string
|
||||
sender?: string
|
||||
type?: string
|
||||
imageMd5?: string
|
||||
imageDatName?: string
|
||||
} {
|
||||
const referMsgStart = content.indexOf('<refermsg>')
|
||||
const referMsgEnd = content.indexOf('</refermsg>')
|
||||
if (referMsgStart === -1 || referMsgEnd === -1) return {}
|
||||
@@ -433,8 +447,16 @@ function parseQuoteMessage(content: string): { content?: string; sender?: string
|
||||
switch (referType) {
|
||||
case '1':
|
||||
return { sender, content: sanitizeQuotedContent(referContent), type: referType }
|
||||
case '3':
|
||||
return { sender, content: '[图片]', type: referType }
|
||||
case '3': {
|
||||
const image = parseImageMessage(referContent)
|
||||
return {
|
||||
sender,
|
||||
content: '[图片]',
|
||||
type: referType,
|
||||
imageMd5: image.type === 'image' ? image.md5 : undefined,
|
||||
imageDatName: image.type === 'image' ? image.datName : undefined
|
||||
}
|
||||
}
|
||||
case '34':
|
||||
return { sender, content: '[语音]', type: referType }
|
||||
case '43':
|
||||
|
||||
@@ -34,6 +34,8 @@ export interface FormattedContact {
|
||||
md5: string
|
||||
type: 'user' | 'group'
|
||||
avatar?: string
|
||||
wechatNickname?: string
|
||||
remark?: string
|
||||
}
|
||||
|
||||
export interface FormattedMessage {
|
||||
@@ -49,6 +51,11 @@ export interface FormattedMessage {
|
||||
contentData?: ReturnType<typeof parseMessageContent>
|
||||
voiceDataUrl?: string
|
||||
voiceDuration?: number
|
||||
exportMediaUrl?: string
|
||||
exportMediaType?: 'image' | 'video' | 'sticker'
|
||||
exportShowAvatar?: boolean
|
||||
exportMediaError?: string
|
||||
exportAvatarUrl?: string
|
||||
localId?: number
|
||||
serverId?: string
|
||||
createTime?: number
|
||||
@@ -130,7 +137,9 @@ export function listContacts(filter?: string): FormattedContact[] {
|
||||
m_nsNickName: user.nickname || '未知用户',
|
||||
md5,
|
||||
type: isGroup ? 'group' : 'user',
|
||||
avatar: typeof user.avatar === 'string' ? user.avatar : undefined
|
||||
avatar: typeof user.avatar === 'string' ? user.avatar : undefined,
|
||||
wechatNickname: user.wechatNickname,
|
||||
remark: user.remark
|
||||
})
|
||||
}
|
||||
|
||||
@@ -249,6 +258,8 @@ function listSourceMessages(
|
||||
if (inferredMsgType !== msgType || rawMsgType !== msgType) {
|
||||
displayType = MSG_TYPE_DICT[inferredMsgType] || displayType
|
||||
}
|
||||
if (parsed.type === 'quote') displayType = '引用消息'
|
||||
if (parsed.type === 'sticker') displayType = '表情包'
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ export interface Wcdb4Session {
|
||||
username: string
|
||||
nickname: string
|
||||
avatar?: string
|
||||
wechatNickname?: string
|
||||
remark?: string
|
||||
raw: Record<string, unknown>
|
||||
}
|
||||
|
||||
@@ -1672,7 +1674,21 @@ export class Wcdb4Client {
|
||||
'remark',
|
||||
'name'
|
||||
])
|
||||
return { username, nickname, raw: row }
|
||||
const wechatNickname = this.pickString(row, [
|
||||
'wechatNickname',
|
||||
'wechat_nickname',
|
||||
'nickname',
|
||||
'nickName',
|
||||
'name'
|
||||
])
|
||||
const remark = this.pickString(row, [
|
||||
'remark',
|
||||
'remarkName',
|
||||
'remark_name',
|
||||
'contactRemark',
|
||||
'contact_remark'
|
||||
])
|
||||
return { username, nickname, wechatNickname, remark, raw: row }
|
||||
}
|
||||
|
||||
private normalizeMessage(row: Record<string, unknown>): Wcdb4Message {
|
||||
|
||||
@@ -4,6 +4,8 @@ export interface UserContact {
|
||||
m_nsUsrName: string
|
||||
nickname: string
|
||||
avatar?: string
|
||||
wechatNickname?: string
|
||||
remark?: string
|
||||
}
|
||||
|
||||
export interface WechatMessage {
|
||||
@@ -56,8 +58,7 @@ export class WechatDb {
|
||||
initialChatTables?: { name: string; db_number: string }[]
|
||||
) {
|
||||
console.log(`Initializing WechatDb with key length: ${rawKey.trim().length}`)
|
||||
const client =
|
||||
clientOverride || new Wcdb4Client(rawKey, accountRoot)
|
||||
const client = clientOverride || new Wcdb4Client(rawKey, accountRoot)
|
||||
if (!clientOverride) client.open()
|
||||
this.wcdb4Client = client
|
||||
for (const table of initialChatTables || client.getChatTables()) {
|
||||
@@ -74,7 +75,9 @@ export class WechatDb {
|
||||
.map((session) => ({
|
||||
m_nsUsrName: session.username,
|
||||
nickname: session.nickname || session.username,
|
||||
avatar: session.avatar
|
||||
avatar: session.avatar,
|
||||
wechatNickname: session.wechatNickname,
|
||||
remark: session.remark
|
||||
}))
|
||||
.filter((contact) => {
|
||||
if (!keyword) return true
|
||||
@@ -124,9 +127,8 @@ export class WechatDb {
|
||||
public getGroupMember(wxid: string, chatroomId?: string): GroupMemberInfo | null {
|
||||
if (!chatroomId) return null
|
||||
return (
|
||||
this.wcdb4Client
|
||||
.getGroupMembers(chatroomId)
|
||||
.find((member) => member.m_nsUsrName === wxid) || null
|
||||
this.wcdb4Client.getGroupMembers(chatroomId).find((member) => member.m_nsUsrName === wxid) ||
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user