diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1fbead4..71b7605 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,6 +15,8 @@ jobs: steps: - name: Check out git repository uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Install Node.js uses: actions/setup-node@v4 @@ -30,7 +32,39 @@ jobs: npx tsc npx vite build + # --- 生成更新日志步骤 --- + - name: Build Changelog + id: build_changelog + uses: mikepenz/release-changelog-builder-action@v4 + with: + outputFile: "release-notes.md" + configurationJson: | + { + "categories": [ + { + "title": "## 🚀 Features", + "labels": ["feat", "feature"] + }, + { + "title": "## 🐛 Fixes", + "labels": ["fix", "bug"] + }, + { + "title": "## 🧰 Maintenance", + "labels": ["chore", "refactor", "docs", "perf"] + } + ], + "template": "# Release Notes\n\n{{CHANGELOG}}" + } + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # --- 打包并发布步骤 --- - name: Package and Publish env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: npx electron-builder --publish always \ No newline at end of file + run: > + npx electron-builder + --publish always + -c.releaseInfo.releaseNotesFile=release-notes.md + -c.publish.releaseType=release \ No newline at end of file diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 8a2613c..98f6363 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -1,5 +1,8 @@ import * as fs from 'fs' import * as path from 'path' +import * as http from 'http' +import * as https from 'https' +import { fileURLToPath } from 'url' import { ConfigService } from './config' import { wcdbService } from './wcdbService' @@ -298,9 +301,9 @@ class ExportService { sessionId: string, cleanedMyWxid: string, dateRange?: { start: number; end: number } | null - ): Promise<{ rows: any[]; memberSet: Map; firstTime: number | null; lastTime: number | null }> { + ): Promise<{ rows: any[]; memberSet: Map; firstTime: number | null; lastTime: number | null }> { const rows: any[] = [] - const memberSet = new Map() + const memberSet = new Map() let firstTime: number | null = null let lastTime: number | null = null @@ -336,8 +339,11 @@ class ExportService { const memberInfo = await this.getContactInfo(actualSender) if (!memberSet.has(actualSender)) { memberSet.set(actualSender, { - platformId: actualSender, - accountName: memberInfo.displayName + member: { + platformId: actualSender, + accountName: memberInfo.displayName + }, + avatarUrl: memberInfo.avatarUrl }) } @@ -361,6 +367,121 @@ class ExportService { return { rows, memberSet, firstTime, lastTime } } + private resolveAvatarFile(avatarUrl?: string): { data?: Buffer; sourcePath?: string; sourceUrl?: string; ext: string; mime?: string } | null { + if (!avatarUrl) return null + if (avatarUrl.startsWith('data:')) { + const match = /^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/i.exec(avatarUrl) + if (!match) return null + const mime = match[1].toLowerCase() + const data = Buffer.from(match[2], 'base64') + const ext = mime.includes('png') ? '.png' + : mime.includes('gif') ? '.gif' + : mime.includes('webp') ? '.webp' + : '.jpg' + return { data, ext, mime } + } + if (avatarUrl.startsWith('file://')) { + try { + const sourcePath = fileURLToPath(avatarUrl) + const ext = path.extname(sourcePath) || '.jpg' + return { sourcePath, ext } + } catch { + return null + } + } + if (avatarUrl.startsWith('http://') || avatarUrl.startsWith('https://')) { + const url = new URL(avatarUrl) + const ext = path.extname(url.pathname) || '.jpg' + return { sourceUrl: avatarUrl, ext } + } + const sourcePath = avatarUrl + const ext = path.extname(sourcePath) || '.jpg' + return { sourcePath, ext } + } + + private async downloadToBuffer(url: string, remainingRedirects = 2): Promise<{ data: Buffer; mime?: string } | null> { + const client = url.startsWith('https:') ? https : http + return new Promise((resolve) => { + const request = client.get(url, (res) => { + const status = res.statusCode || 0 + if (status >= 300 && status < 400 && res.headers.location && remainingRedirects > 0) { + res.resume() + const redirectedUrl = new URL(res.headers.location, url).href + this.downloadToBuffer(redirectedUrl, remainingRedirects - 1) + .then(resolve) + return + } + if (status < 200 || status >= 300) { + res.resume() + resolve(null) + return + } + const chunks: Buffer[] = [] + res.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))) + res.on('end', () => { + const data = Buffer.concat(chunks) + const mime = typeof res.headers['content-type'] === 'string' ? res.headers['content-type'] : undefined + resolve({ data, mime }) + }) + }) + request.on('error', () => resolve(null)) + request.setTimeout(15000, () => { + request.destroy() + resolve(null) + }) + }) + } + + private async exportAvatars( + members: Array<{ username: string; avatarUrl?: string }> + ): Promise> { + const result = new Map() + if (members.length === 0) return result + + for (const member of members) { + const fileInfo = this.resolveAvatarFile(member.avatarUrl) + if (!fileInfo) continue + try { + let data: Buffer | null = null + let mime = fileInfo.mime + if (fileInfo.data) { + data = fileInfo.data + } else if (fileInfo.sourcePath && fs.existsSync(fileInfo.sourcePath)) { + data = await fs.promises.readFile(fileInfo.sourcePath) + } else if (fileInfo.sourceUrl) { + const downloaded = await this.downloadToBuffer(fileInfo.sourceUrl) + if (downloaded) { + data = downloaded.data + mime = downloaded.mime || mime + } + } + if (!data) continue + const finalMime = mime || this.inferImageMime(fileInfo.ext) + const base64 = data.toString('base64') + result.set(member.username, `data:${finalMime};base64,${base64}`) + } catch { + continue + } + } + + return result + } + + private inferImageMime(ext: string): string { + switch (ext.toLowerCase()) { + case '.png': + return 'image/png' + case '.gif': + return 'image/gif' + case '.webp': + return 'image/webp' + case '.bmp': + return 'image/bmp' + default: + return 'image/jpeg' + } + } + /** * 导出单个会话为 ChatLab 格式 */ @@ -399,7 +520,7 @@ class ExportService { }) const chatLabMessages: ChatLabMessage[] = allMessages.map((msg) => { - const memberInfo = collected.memberSet.get(msg.senderUsername) || { + const memberInfo = collected.memberSet.get(msg.senderUsername)?.member || { platformId: msg.senderUsername, accountName: msg.senderUsername } @@ -412,6 +533,23 @@ class ExportService { } }) + const avatarMap = options.exportAvatars + ? await this.exportAvatars( + [ + ...Array.from(collected.memberSet.entries()).map(([username, info]) => ({ + username, + avatarUrl: info.avatarUrl + })), + { username: sessionId, avatarUrl: sessionInfo.avatarUrl } + ] + ) + : new Map() + + const members = Array.from(collected.memberSet.values()).map((info) => { + const avatar = avatarMap.get(info.member.platformId) + return avatar ? { ...info.member, avatar } : info.member + }) + const chatLabExport: ChatLabExport = { chatlab: { version: '0.0.1', @@ -424,7 +562,7 @@ class ExportService { type: isGroup ? 'group' : 'private', ...(isGroup && { groupId: sessionId }) }, - members: Array.from(collected.memberSet.values()), + members, messages: chatLabMessages } @@ -538,6 +676,29 @@ class ExportService { messages: allMessages } + if (options.exportAvatars) { + const avatarMap = await this.exportAvatars( + [ + ...Array.from(collected.memberSet.entries()).map(([username, info]) => ({ + username, + avatarUrl: info.avatarUrl + })), + { username: sessionId, avatarUrl: sessionInfo.avatarUrl } + ] + ) + const avatars: Record = {} + for (const [username, relPath] of avatarMap.entries()) { + avatars[username] = relPath + } + if (Object.keys(avatars).length > 0) { + detailedExport.session = { + ...detailedExport.session, + avatar: avatars[sessionId] + } + ;(detailedExport as any).avatars = avatars + } + } + fs.writeFileSync(outputPath, JSON.stringify(detailedExport, null, 2), 'utf-8') onProgress?.({ diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index e39dd07..105d29c 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -15,6 +15,7 @@ interface ExportOptions { format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'sql' dateRange: { start: Date; end: Date } | null useAllTime: boolean + exportAvatars: boolean } interface ExportResult { @@ -41,7 +42,8 @@ function ExportPage() { start: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), end: new Date() }, - useAllTime: true + useAllTime: true, + exportAvatars: true }) const loadSessions = useCallback(async () => { @@ -140,6 +142,7 @@ function ExportPage() { const sessionList = Array.from(selectedSessions) const exportOptions = { format: options.format, + exportAvatars: options.exportAvatars, dateRange: options.useAllTime ? null : options.dateRange ? { start: Math.floor(options.dateRange.start.getTime() / 1000), end: Math.floor(options.dateRange.end.getTime() / 1000) @@ -289,6 +292,20 @@ function ExportPage() { +
+

导出头像

+
+ +
+
+

导出位置