综合更新

This commit is contained in:
cc committed 2026-07-06 18:09:09 +08:00
1 parent d733c21145
commit 147f3b7045
57 files changed
+3038 -800

No files matched your search

+20 -1
View File
@@ -449,6 +449,7 @@ class ChatService {
private readonly visibilityAnomalyLogBurst = 3
private visibilityAnomalyLogState = new Map<string, { windowStart: number; total: number; suppressed: number }>()
private readonly contactLabelNameMapCacheTtlMs = 10 * 60 * 1000
private connectInFlight: Promise<{ success: boolean; error?: string }> | null = null
private contactsLoadInFlight: { mode: 'lite' | 'full'; promise: Promise<{ success: boolean; contacts?: ContactInfo[]; error?: string }> } | null = null
private contactsMemoryCache = new Map<'lite' | 'full', { scope: string; updatedAt: number; contacts: ContactInfo[] }>()
private readonly contactsMemoryCacheTtlMs = 3 * 60 * 1000
@@ -561,9 +562,27 @@ class ChatService {
}
/**
* 连接数据库
* 连接数据库(并发调用共享同一次连接过程,避免重复 open)
*/
async connect(): Promise<{ success: boolean; error?: string }> {
if (this.connected && wcdbService.isReady()) {
return { success: true }
}
if (this.connectInFlight) {
return this.connectInFlight
}
const promise = this.connectInternal()
this.connectInFlight = promise
try {
return await promise
} finally {
if (this.connectInFlight === promise) {
this.connectInFlight = null
}
}
}
private async connectInternal(): Promise<{ success: boolean; error?: string }> {
try {
const wxid = String(this.runtimeConfig?.myWxid || this.configService.get('myWxid') || '').trim()
const dbPath = String(this.runtimeConfig?.dbPath || this.configService.get('dbPath') || '').trim()
@@ -5,34 +5,10 @@ import { buildGroupNicknameIdCandidates } from '../../export/contacts/groupNickn
import { appendTransferDesc, isTransferExportContent, resolveTransferDesc } from '../../export/parsers/transferParser';
import { formatTimestamp } from '../../export/utils/timestamp';
import { parallelLimit } from '../../export/utils/parallelLimit';
import { buildMarkdownBlockquote, escapeMarkdownLinkText, escapeMarkdownText, toMarkdownUrl } from '../../export/utils/markdown';
import { ExportDisplayProfile, MediaExportItem } from '../../export/types';
import { wcdbService } from '../../wcdbService';
const escapeMarkdownText = (value: unknown): string => {
return String(value ?? '')
.replace(/\\/g, '\\\\')
.replace(/([`*_{}\[\]<>#+\-.!|])/g, '\\$1')
}
const escapeMarkdownLinkText = (value: unknown): string => {
return escapeMarkdownText(value).replace(/\r?\n/g, ' ')
}
const toMarkdownUrl = (value: string): string => {
return encodeURI(value)
.replace(/\(/g, '%28')
.replace(/\)/g, '%29')
.replace(/</g, '%3C')
.replace(/>/g, '%3E')
}
const buildMarkdownBlockquote = (value: string): string => {
return value
.split(/\r?\n/)
.map(line => `> ${line ? escapeMarkdownText(line) : ''}`)
.join('\n')
}
export class MarkdownFormatter {
constructor(private exportService: any) {}
@@ -0,0 +1,24 @@
export const escapeMarkdownText = (value: unknown): string => {
return String(value ?? '')
.replace(/\\/g, '\\\\')
.replace(/([`*_{}\[\]<>#+\-.!|])/g, '\\$1')
}
export const escapeMarkdownLinkText = (value: unknown): string => {
return escapeMarkdownText(value).replace(/\r?\n/g, ' ')
}
export const toMarkdownUrl = (value: string): string => {
return encodeURI(value)
.replace(/\(/g, '%28')
.replace(/\)/g, '%29')
.replace(/</g, '%3C')
.replace(/>/g, '%3E')
}
export const buildMarkdownBlockquote = (value: string): string => {
return value
.split(/\r?\n/)
.map(line => `> ${line ? escapeMarkdownText(line) : ''}`)
.join('\n')
}
+5 -5
View File
@@ -1453,9 +1453,9 @@ class HttpService {
}
const rawFormat = String(url.searchParams.get('format') || 'json').trim().toLowerCase()
const format = rawFormat === 'arkme-json' ? 'arkmejson' : rawFormat
if (!['json', 'html', 'arkmejson'].includes(format)) {
this.sendError(res, 400, 'Invalid format, supported: json/html/arkmejson')
const format = rawFormat === 'arkme-json' ? 'arkmejson' : rawFormat === 'md' ? 'markdown' : rawFormat
if (!['json', 'html', 'arkmejson', 'markdown'].includes(format)) {
this.sendError(res, 400, 'Invalid format, supported: json/html/arkmejson/markdown')
return
}
@@ -1466,7 +1466,7 @@ class HttpService {
const options: {
outputDir: string
format: 'json' | 'html' | 'arkmejson'
format: 'json' | 'html' | 'arkmejson' | 'markdown'
usernames?: string[]
keyword?: string
exportMedia?: boolean
@@ -1477,7 +1477,7 @@ class HttpService {
endTime?: number
} = {
outputDir,
format: format as 'json' | 'html' | 'arkmejson',
format: format as 'json' | 'html' | 'arkmejson' | 'markdown',
usernames,
keyword,
exportMedia: this.parseBooleanParam(url, ['exportMedia'], false)
+125 -23
View File
@@ -8,6 +8,7 @@ import { basename, join } from 'path'
import crypto from 'crypto'
import { WasmService } from './wasmService'
import zlib from 'zlib'
import { escapeMarkdownLinkText, escapeMarkdownText, toMarkdownUrl } from './export/utils/markdown'
export interface SnsLivePhoto {
url: string
@@ -332,6 +333,30 @@ const decodeXmlText = (text: string): string => {
.replace(/&#39;/gi, "'")
}
const normalizeSnsLocationText = (value?: string): string => (
decodeXmlText(String(value || '')).replace(/\s+/g, ' ').trim()
)
/** 解析朋友圈位置的展示文本(POI 优先,附加国家/城市信息) */
const resolveSnsLocationText = (location?: SnsLocation): string => {
if (!location) return ''
const primaryCandidates = [
normalizeSnsLocationText(location.poiName),
normalizeSnsLocationText(location.poiAddressName),
normalizeSnsLocationText(location.label),
normalizeSnsLocationText(location.poiAddress)
].filter(Boolean)
const primary = primaryCandidates[0] || ''
const region = [
normalizeSnsLocationText(location.country),
normalizeSnsLocationText(location.city)
].filter(Boolean).join(' ')
if (primary && region && !primary.includes(region)) {
return `${primary} · ${region}`
}
return primary || region
}
class SnsService {
private configService: ConfigService
private contactCache: ContactCacheService
@@ -1358,7 +1383,7 @@ class SnsService {
*/
async exportTimeline(options: {
outputDir: string
format: 'json' | 'html' | 'arkmejson'
format: 'json' | 'html' | 'arkmejson' | 'markdown'
usernames?: string[]
keyword?: string
exportMedia?: boolean
@@ -1735,6 +1760,11 @@ class SnsService {
}
recordCreatedFileBeforeWrite(outputFilePath)
await writeFile(outputFilePath, JSON.stringify(exportData, null, 2), 'utf-8')
} else if (format === 'markdown') {
outputFilePath = join(outputDir, `朋友圈导出_${timestamp}.md`)
const markdown = this.generateMarkdown(allPosts, { usernames, keyword })
recordCreatedFileBeforeWrite(outputFilePath)
await writeFile(outputFilePath, markdown, 'utf-8')
} else {
// HTML 格式
outputFilePath = join(outputDir, `朋友圈导出_${timestamp}.html`)
@@ -1752,6 +1782,99 @@ class SnsService {
}
}
/**
* 生成朋友圈 Markdown 导出文件
*/
private generateMarkdown(posts: SnsPost[], filters: { usernames?: string[]; keyword?: string }): string {
const formatTime = (ts: number) => {
const d = new Date(ts * 1000)
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日 ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
// 逐行转义正文,行尾补两个空格以在渲染时保留换行
const formatMultilineText = (text: string): string => text
.split(/\r?\n/)
.map(line => escapeMarkdownText(line))
.join(' \n')
const lines: string[] = []
lines.push('# 朋友圈导出')
lines.push('')
lines.push(`- 动态数量: ${posts.length}`)
if (filters.keyword) lines.push(`- 筛选关键词: ${escapeMarkdownText(filters.keyword)}`)
if (filters.usernames && filters.usernames.length > 0) lines.push(`- 筛选用户: ${filters.usernames.length} 人`)
lines.push(`- 导出时间: ${new Date().toLocaleString('zh-CN')}`)
lines.push('- 导出工具: WeFlow')
for (const post of posts) {
lines.push('')
lines.push('---')
lines.push('')
lines.push(`## ${escapeMarkdownText(post.nickname)} · ${formatTime(post.createTime)}`)
lines.push('')
if (post.contentDesc) {
lines.push(formatMultilineText(post.contentDesc))
lines.push('')
}
const locationText = resolveSnsLocationText(post.location)
if (locationText) {
lines.push(`📍 ${escapeMarkdownText(locationText)}`)
lines.push('')
}
const mediaLines: string[] = []
post.media.forEach((m, mi) => {
const indexSuffix = post.media.length > 1 ? ` ${mi + 1}` : ''
const localPath = (m as any).localPath as string | undefined
const isVideo = isVideoUrl(m.url)
if (localPath) {
mediaLines.push(isVideo
? `[视频${indexSuffix}](${toMarkdownUrl(localPath)})`
: `![图片${indexSuffix}](${toMarkdownUrl(localPath)})`)
} else if (m.url) {
mediaLines.push(`[${isVideo ? '视频' : '图片'}${indexSuffix}](${toMarkdownUrl(m.url)})`)
}
const livePhotoLocalPath = m.livePhoto ? (m.livePhoto as any).localPath as string | undefined : undefined
if (livePhotoLocalPath) {
mediaLines.push(`[实况视频${indexSuffix}](${toMarkdownUrl(livePhotoLocalPath)})`)
}
})
if (mediaLines.length > 0) {
// 行尾两个空格产生硬换行,让每个媒体渲染时各占一行
lines.push(mediaLines.join(' \n'))
lines.push('')
}
if (post.linkTitle && post.linkUrl) {
lines.push(`🔗 [${escapeMarkdownLinkText(post.linkTitle)}](${toMarkdownUrl(post.linkUrl)})`)
lines.push('')
}
if (post.likes.length > 0) {
lines.push(`♥ ${post.likes.map(l => escapeMarkdownText(l)).join('、')}`)
lines.push('')
}
if (post.comments.length > 0) {
lines.push('💬 评论:')
lines.push('')
for (const c of post.comments) {
const ref = c.refNickname ? ` 回复 **${escapeMarkdownText(c.refNickname)}**` : ''
lines.push(`- **${escapeMarkdownText(c.nickname)}**${ref}:${escapeMarkdownLinkText(c.content)}`)
}
lines.push('')
}
// 去掉每条动态末尾多余的空行,统一由分隔符控制间距
while (lines[lines.length - 1] === '') lines.pop()
}
lines.push('')
return lines.join('\n')
}
/**
* 生成朋友圈 HTML 导出文件
*/
@@ -1778,27 +1901,6 @@ class SnsService {
const ch = name.charAt(0)
return escapeHtml(ch || '?')
}
const normalizeLocationText = (value?: string): string => (
decodeXmlText(String(value || '')).replace(/\s+/g, ' ').trim()
)
const resolveLocationText = (location?: SnsLocation): string => {
if (!location) return ''
const primaryCandidates = [
normalizeLocationText(location.poiName),
normalizeLocationText(location.poiAddressName),
normalizeLocationText(location.label),
normalizeLocationText(location.poiAddress)
].filter(Boolean)
const primary = primaryCandidates[0] || ''
const region = [
normalizeLocationText(location.country),
normalizeLocationText(location.city)
].filter(Boolean).join(' ')
if (primary && region && !primary.includes(region)) {
return `${primary} · ${region}`
}
return primary || region
}
let filterInfo = ''
if (filters.keyword) filterInfo += `关键词: "${escapeHtml(filters.keyword)}" `
@@ -1822,7 +1924,7 @@ class SnsService {
const linkHtml = post.linkTitle && post.linkUrl
? `<a class="lk" href="${escapeHtml(post.linkUrl)}" target="_blank"><span class="lk-t">${escapeHtml(post.linkTitle)}</span><span class="lk-a">›</span></a>`
: ''
const locationText = resolveLocationText(post.location)
const locationText = resolveSnsLocationText(post.location)
const locationHtml = locationText
? `<div class="loc"><span class="loc-i">📍</span><span class="loc-t">${escapeHtml(locationText)}</span></div>`
: ''
@@ -1,7 +1,11 @@
import { Notification } from "electron";
import { avatarFileCache, AvatarFileCacheService } from "./avatarFileCacheService";
import { avatarFileCache } from "./avatarFileCacheService";
export interface LinuxNotificationData {
// 系统通知服务(Linux / macOS):走各自系统的通知中心(Linux 底层为
// D-Bus/libnotify,macOS 为通知中心),Windows 使用特制的液态玻璃通知窗口。
// 声音、勿扰模式、通知历史均由系统按用户设置管理
export interface SystemNotificationData {
sessionId?: string;
title: string;
content: string;
@@ -14,6 +18,9 @@ export interface LinuxNotificationData {
type NotificationCallback = (payload: unknown) => void;
const isSupportedPlatform =
process.platform === "linux" || process.platform === "darwin";
let notificationCallbacks: NotificationCallback[] = [];
let notificationCounter = 1;
const activeNotifications: Map<number, Notification> = new Map();
@@ -39,20 +46,20 @@ function triggerNotificationCallback(payload: unknown): void {
try {
callback(payload);
} catch (error) {
console.error("[LinuxNotification] Callback error:", error);
console.error("[SystemNotification] Callback error:", error);
}
}
}
export async function showLinuxNotification(
data: LinuxNotificationData,
export async function showSystemNotification(
data: SystemNotificationData,
): Promise<number | null> {
if (process.platform !== "linux") {
if (!isSupportedPlatform) {
return null;
}
if (!Notification.isSupported()) {
console.warn("[LinuxNotification] Notification API is not supported");
console.warn("[SystemNotification] Notification API is not supported");
return null;
}
@@ -91,12 +98,15 @@ export async function showLinuxNotification(
});
notification.on("failed", (_, error) => {
console.error("[LinuxNotification] Notification failed:", error);
console.error("[SystemNotification] Notification failed:", error);
clearNotificationState(notificationId);
});
// Linux 的部分通知服务不会自动过期,需要手动关闭兜底;
// macOS 横幅由系统自动收起并保留在通知中心,手动 close 反而会把它
// 从通知中心移除,因此不做超时关闭
const expireTimeout = data.expireTimeout ?? 5000;
if (expireTimeout > 0) {
if (process.platform === "linux" && expireTimeout > 0) {
const timer = setTimeout(() => {
const currentNotification = activeNotifications.get(notificationId);
if (currentNotification) {
@@ -109,37 +119,16 @@ export async function showLinuxNotification(
notification.show();
console.log(
`[LinuxNotification] Shown notification ${notificationId}: ${data.title}`,
`[SystemNotification] Shown notification ${notificationId}: ${data.title}`,
);
return notificationId;
} catch (error) {
console.error("[LinuxNotification] Failed to show notification:", error);
console.error("[SystemNotification] Failed to show notification:", error);
return null;
}
}
export async function closeLinuxNotification(
notificationId: number,
): Promise<void> {
const notification = activeNotifications.get(notificationId);
if (!notification) return;
notification.close();
clearNotificationState(notificationId);
}
export async function getCapabilities(): Promise<string[]> {
if (process.platform !== "linux") {
return [];
}
if (!Notification.isSupported()) {
return [];
}
return ["native-notification", "click"];
}
export function onNotificationAction(callback: NotificationCallback): void {
notificationCallbacks.push(callback);
}
@@ -153,23 +142,21 @@ export function removeNotificationCallback(
}
}
export async function initLinuxNotificationService(): Promise<void> {
if (process.platform !== "linux") {
console.log("[LinuxNotification] Not on Linux, skipping init");
export async function initSystemNotificationService(): Promise<void> {
if (!isSupportedPlatform) {
console.log("[SystemNotification] Platform uses custom window, skipping init");
return;
}
if (!Notification.isSupported()) {
console.warn("[LinuxNotification] Notification API is not supported");
console.warn("[SystemNotification] Notification API is not supported");
return;
}
const caps = await getCapabilities();
console.log("[LinuxNotification] Service initialized with native API:", caps);
console.log("[SystemNotification] Service initialized for", process.platform);
}
export async function shutdownLinuxNotificationService(): Promise<void> {
// 清理所有活动的通知
export async function shutdownSystemNotificationService(): Promise<void> {
for (const [id, notification] of activeNotifications) {
try {
notification.close();
@@ -182,5 +169,5 @@ export async function shutdownLinuxNotificationService(): Promise<void> {
await avatarFileCache.clearCache();
} catch {}
console.log("[LinuxNotification] Service shutdown complete");
console.log("[SystemNotification] Service shutdown complete");
}
+12 -12
View File
@@ -161,13 +161,18 @@ class VideoService {
return `${dbPath}::${this.cleanWxid(wxid)}`.toLowerCase()
}
/**
* 判断 dbPath 本身是否就是账号目录。
* 无论 wxid_ 开头还是自定义微信号,账号目录下必然存在 db_storage 子目录,
* 以此为判据。不可用路径子串匹配 wxid(#1128:Windows 用户名与微信号相同时,
* `C:\Users\<同名>` 会让子串匹配误判,导致视频目录解析到错误位置)。
*/
private isAccountDir(dirPath: string): boolean {
return existsSync(join(dirPath, 'db_storage'))
}
private resolveVideoBaseDir(dbPath: string, wxid: string): string {
const cleanedWxid = this.cleanWxid(wxid)
const dbPathLower = dbPath.toLowerCase()
const wxidLower = wxid.toLowerCase()
const cleanedWxidLower = cleanedWxid.toLowerCase()
const dbPathContainsWxid = dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxidLower)
if (dbPathContainsWxid) {
if (this.isAccountDir(dbPath)) {
return join(dbPath, 'msg', 'video')
}
@@ -182,12 +187,7 @@ class VideoService {
}
private getHardlinkDbPaths(dbPath: string, wxid: string, cleanedWxid: string): string[] {
const dbPathLower = dbPath.toLowerCase()
const wxidLower = wxid.toLowerCase()
const cleanedWxidLower = cleanedWxid.toLowerCase()
const dbPathContainsWxid = dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxidLower)
if (dbPathContainsWxid) {
if (this.isAccountDir(dbPath)) {
return [join(dbPath, 'db_storage', 'hardlink', 'hardlink.db')]
}