diff --git a/README.md b/README.md index 02185fb..cbf04cf 100644 --- a/README.md +++ b/README.md @@ -38,9 +38,10 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
-
+
+
-
一群满了加二群
+扫到哪个算哪个
## 主要功能 diff --git a/electron/main.ts b/electron/main.ts index fed8391..158ef4e 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -137,6 +137,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 } @@ -687,6 +709,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 9a83ea7..61710a3 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -217,6 +217,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/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{(() => {
+ // 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('')) {
+ indent = Math.max(0, indent - 1);
+ formatted += '\n' + tab.repeat(indent) + part;
+ } else if (part.endsWith('/>')) {
+ formatted += '\n' + tab.repeat(indent) + part;
+ } else {
+ formatted += '\n' + tab.repeat(indent) + part;
+ indent++;
+ }
+ }
+
+ return formatted.trim();
+ })()}
+
+