diff --git a/electron/main.ts b/electron/main.ts index 5900356..5b7555a 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -136,6 +136,28 @@ function createWindow(options: { autoShow?: boolean } = {}) { win.loadFile(join(__dirname, '../dist/index.html')) } + // 拦截请求,修改 Referer 和 User-Agent 以通过微信 CDN 鉴权 + session.defaultSession.webRequest.onBeforeSendHeaders( + { + urls: [ + '*://*.qpic.cn/*', + '*://*.qlogo.cn/*', + '*://*.wechat.com/*', + '*://*.weixin.qq.com/*' + ] + }, + (details, callback) => { + details.requestHeaders['User-Agent'] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x63090719) XWEB/8351" + details.requestHeaders['Accept'] = "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8" + details.requestHeaders['Accept-Encoding'] = "gzip, deflate, br" + details.requestHeaders['Accept-Language'] = "zh-CN,zh;q=0.9" + details.requestHeaders['Referer'] = "https://servicewechat.com/" + details.requestHeaders['Connection'] = "keep-alive" + details.requestHeaders['Range'] = "bytes=0-" + callback({ cancel: false, requestHeaders: details.requestHeaders }) + } + ) + return win } @@ -682,6 +704,14 @@ function registerIpcHandlers() { return snsService.getTimeline(limit, offset, usernames, keyword, startTime, endTime) }) + ipcMain.handle('sns:debugResource', async (_, url: string) => { + return snsService.debugResource(url) + }) + + ipcMain.handle('sns:proxyImage', async (_, url: string) => { + return snsService.proxyImage(url) + }) + // 私聊克隆 diff --git a/electron/preload.ts b/electron/preload.ts index 6fa3c36..f53364f 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -214,6 +214,8 @@ contextBridge.exposeInMainWorld('electronAPI', { // 朋友圈 sns: { getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => - ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime) + ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime), + debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url), + proxyImage: (url: string) => ipcRenderer.invoke('sns:proxyImage', url) } }) diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 2c3c143..57e4206 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -328,7 +328,7 @@ class ChatService { const cached = this.avatarCache.get(username) // 如果缓存有效且有头像,直接使用;如果没有头像,也需要重新尝试获取 // 额外检查:如果头像是无效的 hex 格式(以 ffd8 开头),也需要重新获取 - const isValidAvatar = cached?.avatarUrl && + const isValidAvatar = cached?.avatarUrl && !cached.avatarUrl.includes('base64,ffd8') // 检测错误的 hex 格式 if (cached && now - cached.updatedAt < this.avatarCacheTtlMs && isValidAvatar) { result[username] = { @@ -3098,7 +3098,7 @@ class ChatService { private resolveAccountDir(dbPath: string, wxid: string): string | null { const normalized = dbPath.replace(/[\\\\/]+$/, '') - + // 如果 dbPath 本身指向 db_storage 目录下的文件(如某个 .db 文件) // 则向上回溯到账号目录 if (basename(normalized).toLowerCase() === 'db_storage') { @@ -3108,14 +3108,14 @@ class ChatService { if (basename(dir).toLowerCase() === 'db_storage') { return dirname(dir) } - + // 否则,dbPath 应该是数据库根目录(如 xwechat_files) // 账号目录应该是 {dbPath}/{wxid} const accountDirWithWxid = join(normalized, wxid) if (existsSync(accountDirWithWxid)) { return accountDirWithWxid } - + // 兜底:返回 dbPath 本身(可能 dbPath 已经是账号目录) return normalized } diff --git a/electron/services/snsService.ts b/electron/services/snsService.ts index bf674f4..4d5c2cf 100644 --- a/electron/services/snsService.ts +++ b/electron/services/snsService.ts @@ -2,6 +2,25 @@ import { wcdbService } from './wcdbService' import { ConfigService } from './config' import { ContactCacheService } from './contactCacheService' +export interface SnsLivePhoto { + url: string + thumb: string + md5?: string + token?: string + key?: string + encIdx?: string +} + +export interface SnsMedia { + url: string + thumb: string + md5?: string + token?: string + key?: string + encIdx?: string + livePhoto?: SnsLivePhoto +} + export interface SnsPost { id: string username: string @@ -10,11 +29,25 @@ export interface SnsPost { createTime: number contentDesc: string type?: number - media: { url: string; thumb: string }[] + media: SnsMedia[] likes: string[] comments: { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string }[] + rawXml?: string // 原始 XML 数据 } +const fixSnsUrl = (url: string, token?: string) => { + if (!url) return url; + + // 1. 统一使用 https + // 2. 将 /150 (缩略图) 强制改为 /0 (原图) + let fixedUrl = url.replace('http://', 'https://').replace(/\/150($|\?)/, '/0$1'); + + if (!token || fixedUrl.includes('token=')) return fixedUrl; + + const connector = fixedUrl.includes('?') ? '&' : '?'; + return `${fixedUrl}${connector}token=${token}&idx=1`; +}; + class SnsService { private contactCache: ContactCacheService @@ -35,14 +68,50 @@ class SnsService { }) if (result.success && result.timeline) { - const enrichedTimeline = result.timeline.map((post: any) => { + const enrichedTimeline = result.timeline.map((post: any, index: number) => { const contact = this.contactCache.get(post.username) - // 修复媒体 URL,如果是 http 则尝试用 https (虽然 qpic 可能不支持强制 https,但通常支持) - const fixedMedia = post.media.map((m: any) => ({ - url: m.url.replace('http://', 'https://'), - thumb: m.thumb.replace('http://', 'https://') - })) + // 修复媒体 URL + const fixedMedia = post.media.map((m: any, mIdx: number) => { + const base = { + url: fixSnsUrl(m.url, m.token), + thumb: fixSnsUrl(m.thumb, m.token), + md5: m.md5, + token: m.token, + key: m.key, + encIdx: m.encIdx || m.enc_idx, // 兼容不同命名 + livePhoto: m.livePhoto ? { + ...m.livePhoto, + url: fixSnsUrl(m.livePhoto.url, m.livePhoto.token), + thumb: fixSnsUrl(m.livePhoto.thumb, m.livePhoto.token), + token: m.livePhoto.token, + key: m.livePhoto.key + } : undefined + } + + // [MOCK] 模拟数据:如果后端没返回 key (说明 DLL 未更新),注入一些 Mock 数据以便前端开发 + if (!base.key) { + base.key = 'mock_key_for_dev' + if (!base.token) { + base.token = 'mock_token_for_dev' + base.url = fixSnsUrl(base.url, base.token) + base.thumb = fixSnsUrl(base.thumb, base.token) + } + base.encIdx = '1' + + // 强制给第一个帖子的第一张图加 LivePhoto 模拟 + if (index === 0 && mIdx === 0 && !base.livePhoto) { + base.livePhoto = { + url: fixSnsUrl('https://tm.sh/d4cb0.mp4', 'mock_live_token'), + thumb: base.thumb, + token: 'mock_live_token', + key: 'mock_live_key' + } + } + } + + return base + }) return { ...post, @@ -59,6 +128,128 @@ class SnsService { console.log('[SnsService] Returning result:', result) return result } + async debugResource(url: string): Promise<{ success: boolean; status?: number; headers?: any; error?: string }> { + return new Promise((resolve) => { + try { + const { app, net } = require('electron') + // Remove mocking 'require' if it causes issues, but here we need 'net' or 'https' + // implementing with 'https' for reliability if 'net' is main-process only special + const https = require('https') + const urlObj = new URL(url) + + const options = { + hostname: urlObj.hostname, + path: urlObj.pathname + urlObj.search, + method: 'GET', + headers: { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x63090719) XWEB/8351", + "Accept": "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", + "Accept-Encoding": "gzip, deflate, br", + "Accept-Language": "zh-CN,zh;q=0.9", + "Referer": "https://servicewechat.com/", + "Connection": "keep-alive", + "Range": "bytes=0-10" // Keep our range check + } + } + + const req = https.request(options, (res: any) => { + resolve({ + success: true, + status: res.statusCode, + headers: { + 'x-enc': res.headers['x-enc'], + 'content-length': res.headers['content-length'], + 'content-type': res.headers['content-type'] + } + }) + req.destroy() // We only need headers + }) + + req.on('error', (e: any) => { + resolve({ success: false, error: e.message }) + }) + + req.end() + } catch (e: any) { + resolve({ success: false, error: e.message }) + } + }) + } + + private imageCache = new Map() + + async proxyImage(url: string): Promise<{ success: boolean; dataUrl?: string; error?: string }> { + // Check cache + if (this.imageCache.has(url)) { + return { success: true, dataUrl: this.imageCache.get(url) } + } + + return new Promise((resolve) => { + try { + const https = require('https') + const zlib = require('zlib') + const urlObj = new URL(url) + + const options = { + hostname: urlObj.hostname, + path: urlObj.pathname + urlObj.search, + method: 'GET', + headers: { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x63090719) XWEB/8351", + "Accept": "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", + "Accept-Encoding": "gzip, deflate, br", + "Accept-Language": "zh-CN,zh;q=0.9", + "Referer": "https://servicewechat.com/", + "Connection": "keep-alive" + } + } + + const req = https.request(options, (res: any) => { + if (res.statusCode !== 200) { + resolve({ success: false, error: `HTTP ${res.statusCode}` }) + return + } + + const chunks: Buffer[] = [] + let stream = res + + // Handle gzip compression + const encoding = res.headers['content-encoding'] + if (encoding === 'gzip') { + stream = res.pipe(zlib.createGunzip()) + } else if (encoding === 'deflate') { + stream = res.pipe(zlib.createInflate()) + } else if (encoding === 'br') { + stream = res.pipe(zlib.createBrotliDecompress()) + } + + stream.on('data', (chunk: Buffer) => chunks.push(chunk)) + stream.on('end', () => { + const buffer = Buffer.concat(chunks) + const contentType = res.headers['content-type'] || 'image/jpeg' + const base64 = buffer.toString('base64') + const dataUrl = `data:${contentType};base64,${base64}` + + // Cache + this.imageCache.set(url, dataUrl) + + resolve({ success: true, dataUrl }) + }) + stream.on('error', (e: any) => { + resolve({ success: false, error: e.message }) + }) + }) + + req.on('error', (e: any) => { + resolve({ success: false, error: e.message }) + }) + + req.end() + } catch (e: any) { + resolve({ success: false, error: e.message }) + } + }) + } } export const snsService = new SnsService() diff --git a/resources/wcdb_api.dll b/resources/wcdb_api.dll index e9e509e..cd29973 100644 Binary files a/resources/wcdb_api.dll and b/resources/wcdb_api.dll differ diff --git a/src/pages/SnsPage.scss b/src/pages/SnsPage.scss index 2bcac8e..4994949 100644 --- a/src/pages/SnsPage.scss +++ b/src/pages/SnsPage.scss @@ -739,6 +739,59 @@ cursor: zoom-in; } + .live-badge { + position: absolute; + top: 6px; + right: 6px; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + color: white; + font-size: 10px; + font-weight: 700; + padding: 2px 6px; + border-radius: 4px; + display: flex; + align-items: center; + gap: 2px; + pointer-events: none; + z-index: 2; + transition: opacity 0.2s; + } + + .download-btn-overlay { + position: absolute; + bottom: 6px; + right: 6px; + width: 28px; + height: 28px; + border-radius: 50%; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); + border: 1px solid rgba(255, 255, 255, 0.3); + color: white; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + opacity: 0; + transform: translateY(10px); + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + z-index: 2; + + &:hover { + background: rgba(0, 0, 0, 0.7); + transform: scale(1.1); + border-color: rgba(255, 255, 255, 0.8); + } + } + + &:hover { + .download-btn-overlay { + opacity: 1; + transform: translateY(0); + } + } + .media-error-placeholder { position: absolute; inset: 0; @@ -937,4 +990,197 @@ transform: scale(1); opacity: 1; } +} + +// Debug Dialog Styles +.debug-btn { + margin-left: auto; + background: transparent; + border: 1px solid var(--border-color); + color: var(--text-secondary); + padding: 6px; + border-radius: 6px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; + + &:hover { + background: var(--hover-bg); + color: var(--accent-color); + border-color: var(--accent-color); + } +} + +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; + backdrop-filter: blur(4px); +} + +.debug-dialog { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 12px; + width: 90%; + max-width: 800px; + max-height: 85vh; + display: flex; + flex-direction: column; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + + .debug-dialog-header { + padding: 16px 20px; + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: center; + justify-content: space-between; + + h3 { + margin: 0; + font-size: 16px; + font-weight: 600; + color: var(--text-primary); + } + + .close-btn { + background: transparent; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 4px; + display: flex; + align-items: center; + border-radius: 4px; + transition: all 0.2s; + + &:hover { + background: var(--hover-bg); + color: var(--accent-color); + } + } + } + + .debug-dialog-body { + flex: 1; + overflow-y: auto; + padding: 20px; + + .debug-section { + margin-bottom: 24px; + padding-bottom: 20px; + border-bottom: 1px solid var(--border-color); + + &:last-child { + border-bottom: none; + } + + h4 { + margin: 0 0 12px 0; + font-size: 14px; + font-weight: 600; + color: var(--accent-color); + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .debug-item { + display: flex; + gap: 12px; + padding: 8px 0; + align-items: flex-start; + + .debug-key { + font-weight: 500; + color: var(--text-secondary); + min-width: 140px; + font-size: 13px; + font-family: 'Consolas', 'Microsoft YaHei', 'SimHei', monospace; + } + + .debug-value { + flex: 1; + color: var(--text-primary); + font-size: 13px; + word-break: break-all; + font-family: 'Consolas', 'Microsoft YaHei', 'SimHei', monospace; + user-select: text; + cursor: text; + padding: 2px 0; + } + } + + .media-debug-item { + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 12px; + margin-bottom: 12px; + + .media-debug-header { + font-weight: 600; + color: var(--text-primary); + margin-bottom: 8px; + padding-bottom: 8px; + border-bottom: 1px solid var(--border-color); + } + + .live-photo-debug { + margin-top: 12px; + padding-top: 12px; + border-top: 1px dashed var(--border-color); + + .live-photo-label { + font-weight: 500; + color: var(--accent-color); + margin-bottom: 8px; + font-size: 13px; + } + } + } + + .json-code { + background: var(--bg-tertiary); + color: var(--text-primary); + padding: 16px; + border-radius: 8px; + border: 1px solid var(--border-color); + overflow-x: auto; + font-family: 'Consolas', 'Monaco', monospace; + font-size: 12px; + line-height: 1.5; + user-select: all; + max-height: 400px; + overflow-y: auto; + } + + .copy-json-btn { + margin-top: 12px; + padding: 8px 16px; + background: var(--accent-color); + color: white; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 13px; + font-weight: 500; + transition: all 0.2s; + + &:hover { + background: var(--accent-hover); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(var(--accent-color-rgb), 0.3); + } + } + } + } } \ No newline at end of file diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx index e7947dc..2024bdd 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -1,5 +1,5 @@ -import { useEffect, useState, useRef, useCallback, useMemo } from 'react' -import { RefreshCw, Heart, Search, Calendar, User, X, Filter, Play, ImageIcon } from 'lucide-react' +import { useEffect, useState, useRef, useCallback, useMemo } from 'react' +import { RefreshCw, Heart, Search, Calendar, User, X, Filter, Play, ImageIcon, Zap, Download } from 'lucide-react' import { Avatar } from '../components/Avatar' import { ImagePreview } from '../components/ImagePreview' import JumpToDateDialog from '../components/JumpToDateDialog' @@ -13,29 +13,65 @@ interface SnsPost { createTime: number contentDesc: string type?: number - media: { url: string; thumb: string }[] + media: { + url: string + thumb: string + md5?: string + token?: string + key?: string + encIdx?: string + livePhoto?: { + url: string + thumb: string + token?: string + key?: string + encIdx?: string + } + }[] likes: string[] comments: { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string }[] + rawXml?: string // 原始 XML 数据 } -const MediaItem = ({ url, thumb, onPreview }: { url: string, thumb: string, onPreview: () => void }) => { +const MediaItem = ({ media, onPreview }: { media: any, onPreview: () => void }) => { const [error, setError] = useState(false); + const { url, thumb, livePhoto } = media; + const isLive = !!livePhoto; + const targetUrl = thumb || url; + + const handleDownload = (e: React.MouseEvent) => { + e.stopPropagation(); + + let downloadUrl = url; + let downloadKey = media.key || ''; + + if (isLive && media.livePhoto) { + downloadUrl = media.livePhoto.url; + downloadKey = media.livePhoto.key || ''; + } + + // TODO: 调用后端下载服务 + // window.electronAPI.sns.download(downloadUrl, downloadKey); + }; return ( -
- {!error ? ( - setError(true)} - /> - ) : ( -
- +
+ setError(true)} + /> + {isLive && ( +
+ + LIVE
)} +
); }; @@ -65,6 +101,7 @@ export default function SnsPage() { const [showJumpDialog, setShowJumpDialog] = useState(false) const [jumpTargetDate, setJumpTargetDate] = useState(undefined) const [previewImage, setPreviewImage] = useState(null) + const [debugPost, setDebugPost] = useState(null) const postsContainerRef = useRef(null) @@ -515,6 +552,19 @@ export default function SnsPage() {
{post.nickname}
{formatTime(post.createTime)}
+
@@ -528,7 +578,7 @@ export default function SnsPage() { ) : post.media.length > 0 && (
{post.media.map((m, idx) => ( - setPreviewImage(m.url)} /> + setPreviewImage(m.url)} /> ))}
)} @@ -605,6 +655,154 @@ export default function SnsPage() { }} currentDate={jumpTargetDate || new Date()} /> + + {/* Debug Info Dialog */} + {debugPost && ( +
setDebugPost(null)}> +
e.stopPropagation()}> +
+

原始数据 - {debugPost.nickname}

+ +
+
+ +
+

ℹ 基本信息

+
+ ID: + {debugPost.id} +
+
+ 用户名: + {debugPost.username} +
+
+ 昵称: + {debugPost.nickname} +
+
+ 时间: + {new Date(debugPost.createTime * 1000).toLocaleString()} +
+
+ 类型: + {debugPost.type} +
+
+ +
+

媒体信息 ({debugPost.media.length} 项)

+ {debugPost.media.map((media, idx) => ( +
+
媒体 {idx + 1}
+
+ URL: + {media.url} +
+
+ 缩略图: + {media.thumb} +
+ {media.md5 && ( +
+ MD5: + {media.md5} +
+ )} + {media.token && ( +
+ Token: + {media.token} +
+ )} + {media.key && ( +
+ Key (解密密钥): + {media.key} +
+ )} + {media.encIdx && ( +
+ Enc Index: + {media.encIdx} +
+ )} + {media.livePhoto && ( +
+
Live Photo 视频部分:
+
+ 视频 URL: + {media.livePhoto.url} +
+
+ 视频缩略图: + {media.livePhoto.thumb} +
+ {media.livePhoto.token && ( +
+ 视频 Token: + {media.livePhoto.token} +
+ )} + {media.livePhoto.key && ( +
+ 视频 Key: + {media.livePhoto.key} +
+ )} +
+ )} +
+ ))} +
+ + {/* 原始 XML */} + {debugPost.rawXml && ( +
+

原始 XML 数据

+
{(() => {
+                                        // XML 缩进格式化
+                                        let formatted = '';
+                                        let indent = 0;
+                                        const tab = '  ';
+                                        const parts = debugPost.rawXml.split(/(<[^>]+>)/g).filter(p => p.trim());
+
+                                        for (const part of parts) {
+                                            if (!part.startsWith('<')) {
+                                                if (part.trim()) formatted += part;
+                                                continue;
+                                            }
+
+                                            if (part.startsWith('')) {
+                                                formatted += '\n' + tab.repeat(indent) + part;
+                                            } else {
+                                                formatted += '\n' + tab.repeat(indent) + part;
+                                                indent++;
+                                            }
+                                        }
+
+                                        return formatted.trim();
+                                    })()}
+ +
+ )} +
+
+
+ )}
) } diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index abd1d6f..034ea62 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -339,6 +339,8 @@ export interface ElectronAPI { }> error?: string }> + debugResource: (url: string) => Promise<{ success: boolean; status?: number; headers?: any; error?: string }> + proxyImage: (url: string) => Promise<{ success: boolean; dataUrl?: string; error?: string }> } }