import { app, BrowserWindow } from 'electron' import fs from 'fs-extra' import os from 'os' import path from 'path' import { GroupReportExportRequest, GroupReportExportResult, ReportHeat } from '../shared/group-report' const TEMPLATE_NAME = 'mobile_daily_report.html' const escapeHtml = (value: unknown): string => String(value ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, ''') const sanitizeFileName = (value: string): string => value .replace(/[\\/:*?"<>|]/g, '_') .replace(/\s+/g, ' ') .trim() || '未命名群聊' const hashName = (name: string): number => { let hash = 0 for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) >>> 0 return hash } const fallbackAvatar = (name: string): string => { const hue = hashName(name) % 360 const initial = escapeHtml(Array.from(name.trim())[0] || '?') const svg = `${initial}` return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}` } const imageMimeType = (contentType: string | null, source: string): string => { if (contentType?.startsWith('image/')) return contentType.split(';')[0] const extension = path.extname(source).toLowerCase() if (extension === '.png') return 'image/png' if (extension === '.webp') return 'image/webp' if (extension === '.gif') return 'image/gif' return 'image/jpeg' } const embedAvatar = async (source: string | undefined, name: string): Promise => { if (!source) return fallbackAvatar(name) if (/^data:image\/[a-z0-9.+-]+;base64,[a-z0-9+/=]+$/i.test(source)) return source try { if (/^https?:\/\//i.test(source)) { const response = await fetch(source, { headers: { 'User-Agent': 'Mozilla/5.0 WechatExplorer', Referer: 'https://weixin.qq.com/' }, signal: AbortSignal.timeout(8000) }) if (!response.ok) throw new Error(`HTTP ${response.status}`) const mime = imageMimeType(response.headers.get('content-type'), source) return `data:${mime};base64,${Buffer.from(await response.arrayBuffer()).toString('base64')}` } const localPath = source.startsWith('file://') ? new URL(source) : source const buffer = await fs.readFile(localPath) return `data:${imageMimeType(null, source)};base64,${buffer.toString('base64')}` } catch (error) { console.warn(`[GroupReport] avatar fallback for ${name}:`, error) return fallbackAvatar(name) } } const templatePath = (): string => { const candidates = [ path.join(process.resourcesPath, 'resources', TEMPLATE_NAME), path.join(app.getAppPath(), 'resources', TEMPLATE_NAME), path.join(process.cwd(), 'resources', TEMPLATE_NAME) ] const found = candidates.find((candidate) => fs.existsSync(candidate)) if (!found) throw new Error(`日报模板不存在: ${candidates.join(' | ')}`) return found } const heatClass = (heat: ReportHeat): string => { if (heat === '高') return 'hot' if (heat === '低') return 'blue' return '' } const replacePlaceholder = (html: string, key: string, value: string): string => html.replaceAll(`{{${key}}}`, value) const renderReportHtml = async (request: GroupReportExportRequest): Promise => { const { report, metadata } = request const avatarNames = new Set(metadata.heroParticipants) report.topics.forEach((topic) => topic.participants.forEach((name) => avatarNames.add(name))) report.importantMessages.forEach((message) => avatarNames.add(message.sender)) report.quotes.forEach((quote) => quote.messages.forEach((message) => avatarNames.add(message.sender)) ) report.analytics.topSpeakers.forEach((speaker) => avatarNames.add(speaker.name)) const avatars = new Map() await Promise.all( Array.from(avatarNames).map(async (name) => { avatars.set(name, await embedAvatar(metadata.avatars[name], name)) }) ) const avatar = (name: string): string => avatars.get(name) || fallbackAvatar(name) const heroNames = metadata.heroParticipants.slice(0, 4) while (heroNames.length < 4) heroNames.push(metadata.groupName) const heroAvatars = heroNames .map((name) => ``) .join('') const topicCards = report.topics .map( (topic) => ` ${escapeHtml(topic.title)}${escapeHtml(topic.heat)}热 ${escapeHtml(topic.timeRange)} ${escapeHtml(topic.summary)} ${topic.conclusion ? `${escapeHtml(topic.conclusion)}` : ''} ${topic.participants .slice(0, 5) .map( (name) => `${escapeHtml(name)}` ) .join('')} ${topic.keywords.map((word) => `${escapeHtml(word)}`).join('')} ` ) .join('') const resourceItems = report.resources .map( (resource) => `${escapeHtml(resource.title)}${resource.sender ? ` · ${escapeHtml(resource.sender)}` : ''}${escapeHtml(resource.description)}` ) .join('') const importantMessages = report.importantMessages .map( (message) => ` ${escapeHtml(message.sender)}${escapeHtml(message.time)} ${escapeHtml(message.content)}${escapeHtml(message.note)} ` ) .join('') const quoteBlocks = report.quotes .map( (quote) => `${quote.messages .map( ( message ) => ` ${escapeHtml(message.sender)}${escapeHtml(message.content)} ` ) .join('')}${escapeHtml(quote.note)}` ) .join('') const qaCards = report.qa .map( (item) => `Q:${escapeHtml(item.question)}A:${escapeHtml(item.answer)}${item.answerer ? ` — ${escapeHtml(item.answerer)}` : ''}` ) .join('') const maxHeat = Math.max(1, ...report.analytics.topicHeat.map((item) => item.score)) const heatBars = report.analytics.topicHeat .map( (item) => `${escapeHtml(item.topic)}` ) .join('') const rankItems = report.analytics.topSpeakers .slice(0, 5) .map( (speaker, index) => `${index + 1}. ${escapeHtml(speaker.name)}${Math.max(0, speaker.count)} 条` ) .join('') const cloudTags = report.keywords .slice(0, 15) .map( (word, index) => `${escapeHtml(word)}` ) .join('') let html = await fs.readFile(templatePath(), 'utf8') const values: Record = { REPORT_TITLE: escapeHtml(`${metadata.groupName}日报`), GROUP_NAME: escapeHtml(metadata.groupName), DATE_RANGE: escapeHtml(metadata.dateRange), RECORD_NOTE: escapeHtml(`${metadata.recordNote} ${report.overview}`.trim()), HERO_AVATARS: heroAvatars, MESSAGE_COUNT: String(metadata.messageCount), ACTIVE_USERS: String(metadata.activeUsers), TIME_SPAN: escapeHtml(metadata.timeSpan), TOPIC_COUNT: String(report.topics.length), TOPIC_CARDS: topicCards, RESOURCES_EMPTY_CLASS: report.resources.length ? '' : 'empty-section', RESOURCE_ITEMS: resourceItems, MESSAGES_EMPTY_CLASS: report.importantMessages.length ? '' : 'empty-section', IMPORTANT_MESSAGES: importantMessages, QUOTES_EMPTY_CLASS: report.quotes.length ? '' : 'empty-section', QUOTE_BLOCKS: quoteBlocks, QA_EMPTY_CLASS: report.qa.length ? '' : 'empty-section', QA_CARDS: qaCards, HEAT_BARS: heatBars, RANK_ITEMS: rankItems, ACTIVITY_TIMELINE: escapeHtml(report.analytics.activeTimeline), CLOUD_TAGS: cloudTags, GENERATED_AT: escapeHtml(metadata.generatedAt), FOOTER_NOTE: escapeHtml(metadata.footerNote) } for (const [key, value] of Object.entries(values)) html = replacePlaceholder(html, key, value) return html } const captureFullPage = async (htmlPath: string, pngPath: string): Promise => { const reportWindow = new BrowserWindow({ show: false, width: 430, height: 800, frame: false, backgroundColor: '#f3f5f7', webPreferences: { sandbox: true } }) try { await reportWindow.loadFile(htmlPath) await reportWindow.webContents.executeJavaScript(`Promise.all([ document.fonts.ready, ...Array.from(document.images).map((img) => img.complete ? Promise.resolve() : new Promise((resolve) => { img.addEventListener('load', resolve, { once: true }); img.addEventListener('error', resolve, { once: true }); })) ])`) reportWindow.webContents.debugger.attach('1.3') const metrics = (await reportWindow.webContents.debugger.sendCommand( 'Page.getLayoutMetrics' )) as { cssContentSize: { width: number; height: number } } const width = Math.max(430, Math.ceil(metrics.cssContentSize.width)) const height = Math.ceil(metrics.cssContentSize.height) const screenshot = (await reportWindow.webContents.debugger.sendCommand( 'Page.captureScreenshot', { format: 'png', captureBeyondViewport: true, fromSurface: true, clip: { x: 0, y: 0, width, height, scale: 1 } } )) as { data: string } const png = Buffer.from(screenshot.data, 'base64') if (png.length < 1000) throw new Error('生成的日报图片为空') await fs.writeFile(pngPath, png) return `data:image/png;base64,${screenshot.data}` } finally { if (reportWindow.webContents.debugger.isAttached()) { reportWindow.webContents.debugger.detach() } reportWindow.destroy() } } export const exportGroupReport = async ( request: GroupReportExportRequest ): Promise => { try { const outputDir = path.join(os.homedir(), 'Documents', '微信聊天记录') await fs.ensureDir(outputDir) const baseName = `${sanitizeFileName(request.metadata.groupName)}日报_${request.metadata.reportDate}_可视化长图` const htmlPath = path.join(outputDir, `${baseName}.html`) const pngPath = path.join(outputDir, `${baseName}.png`) const html = await renderReportHtml(request) await fs.writeFile(htmlPath, html, 'utf8') const imageDataUrl = await captureFullPage(htmlPath, pngPath) return { success: true, htmlPath, pngPath, imageDataUrl } } catch (error) { console.error('[GroupReport] export failed:', error) return { success: false, error: error instanceof Error ? error.message : String(error) } } }
${escapeHtml(topic.summary)}
${escapeHtml(topic.conclusion)}