This commit is contained in:
xuncha
2026-02-16 16:28:04 +08:00
parent 28e38f73f8
commit ea0dad132c
5 changed files with 253 additions and 128 deletions

View File

@@ -932,8 +932,10 @@ function registerIpcHandlers() {
return snsService.debugResource(url) return snsService.debugResource(url)
}) })
ipcMain.handle('sns:proxyImage', async (_, url: string) => { ipcMain.handle('sns:proxyImage', async (_, payload: string | { url: string; key?: string | number }) => {
return snsService.proxyImage(url) const url = typeof payload === 'string' ? payload : payload?.url
const key = typeof payload === 'string' ? undefined : payload?.key
return snsService.proxyImage(url, key)
}) })
// 私聊克隆 // 私聊克隆

View File

@@ -271,7 +271,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => 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), debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url),
proxyImage: (url: string) => ipcRenderer.invoke('sns:proxyImage', url) proxyImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:proxyImage', payload)
}, },
// Llama AI // Llama AI

View File

@@ -1,6 +1,8 @@
import { wcdbService } from './wcdbService' import { wcdbService } from './wcdbService'
import { ConfigService } from './config' import { ConfigService } from './config'
import { ContactCacheService } from './contactCacheService' import { ContactCacheService } from './contactCacheService'
import { existsSync } from 'fs'
import { basename, join } from 'path'
export interface SnsLivePhoto { export interface SnsLivePhoto {
url: string url: string
@@ -32,24 +34,57 @@ export interface SnsPost {
media: SnsMedia[] media: SnsMedia[]
likes: string[] likes: string[]
comments: { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string }[] comments: { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string }[]
rawXml?: string // 原始 XML 数据 rawXml?: string
} }
const WEIXIN_DLL_OFFSET = 0x2674280n
const fixSnsUrl = (url: string, token?: string) => { const fixSnsUrl = (url: string, token?: string) => {
if (!url) return url; if (!url) return url
// 1. 统一使用 https let fixedUrl = url.replace('http://', 'https://').replace(/\/150($|\?)/, '/0$1')
// 2. 将 /150 (缩略图) 强制改为 /0 (原图) if (!token || fixedUrl.includes('token=')) return fixedUrl
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`
}
const connector = fixedUrl.includes('?') ? '&' : '?'; const detectImageMime = (buf: Buffer, fallback: string = 'image/jpeg') => {
return `${fixedUrl}${connector}token=${token}&idx=1`; if (!buf || buf.length < 4) return fallback
}; if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return 'image/jpeg'
if (
buf.length >= 8 &&
buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47 &&
buf[4] === 0x0d && buf[5] === 0x0a && buf[6] === 0x1a && buf[7] === 0x0a
) return 'image/png'
if (buf.length >= 6) {
const sig = buf.subarray(0, 6).toString('ascii')
if (sig === 'GIF87a' || sig === 'GIF89a') return 'image/gif'
}
if (
buf.length >= 12 &&
buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46 &&
buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50
) return 'image/webp'
if (buf[0] === 0x42 && buf[1] === 0x4d) return 'image/bmp'
return fallback
}
class SnsService { class SnsService {
private contactCache: ContactCacheService private contactCache: ContactCacheService
private imageCache = new Map<string, string>()
private nativeDecryptInit = false
private nativeDecryptReady = false
private nativeDecryptError = ''
private nativeDecryptDllPath = ''
private nativeKoffi: any = null
private nativeWeixinLib: any = null
private nativeDecryptFn: any = null
constructor() { constructor() {
const config = new ConfigService() const config = new ConfigService()
@@ -57,57 +92,29 @@ class SnsService {
} }
async getTimeline(limit: number = 20, offset: number = 0, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: SnsPost[]; error?: string }> { async getTimeline(limit: number = 20, offset: number = 0, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: SnsPost[]; error?: string }> {
const result = await wcdbService.getSnsTimeline(limit, offset, usernames, keyword, startTime, endTime) const result = await wcdbService.getSnsTimeline(limit, offset, usernames, keyword, startTime, endTime)
if (result.success && result.timeline) { if (result.success && result.timeline) {
const enrichedTimeline = result.timeline.map((post: any, index: number) => { const enrichedTimeline = result.timeline.map((post: any) => {
const contact = this.contactCache.get(post.username) const contact = this.contactCache.get(post.username)
const fixedMedia = (post.media || []).map((m: any) => ({
// 修复媒体 URL
const fixedMedia = post.media.map((m: any, mIdx: number) => {
const base = {
url: fixSnsUrl(m.url, m.token), url: fixSnsUrl(m.url, m.token),
thumb: fixSnsUrl(m.thumb, m.token), thumb: fixSnsUrl(m.thumb, m.token),
md5: m.md5, md5: m.md5,
token: m.token, token: m.token,
key: m.key, key: m.key,
encIdx: m.encIdx || m.enc_idx, // 兼容不同命名 encIdx: m.encIdx || m.enc_idx,
livePhoto: m.livePhoto ? { livePhoto: m.livePhoto
? {
...m.livePhoto, ...m.livePhoto,
url: fixSnsUrl(m.livePhoto.url, m.livePhoto.token), url: fixSnsUrl(m.livePhoto.url, m.livePhoto.token),
thumb: fixSnsUrl(m.livePhoto.thumb, m.livePhoto.token), thumb: fixSnsUrl(m.livePhoto.thumb, m.livePhoto.token),
token: m.livePhoto.token, token: m.livePhoto.token,
key: m.livePhoto.key key: m.livePhoto.key,
} : undefined encIdx: m.livePhoto.encIdx || m.livePhoto.enc_idx
} }
: 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 { return {
...post, ...post,
@@ -116,20 +123,15 @@ class SnsService {
media: fixedMedia media: fixedMedia
} }
}) })
return { ...result, timeline: enrichedTimeline } return { ...result, timeline: enrichedTimeline }
} }
return result return result
} }
async debugResource(url: string): Promise<{ success: boolean; status?: number; headers?: any; error?: string }> { async debugResource(url: string): Promise<{ success: boolean; status?: number; headers?: any; error?: string }> {
return new Promise((resolve) => { return new Promise((resolve) => {
try { 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 https = require('https')
const urlObj = new URL(url) const urlObj = new URL(url)
@@ -138,13 +140,12 @@ class SnsService {
path: urlObj.pathname + urlObj.search, path: urlObj.pathname + urlObj.search,
method: 'GET', method: 'GET',
headers: { 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", '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': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
"Accept-Encoding": "gzip, deflate, br", 'Accept-Encoding': 'gzip, deflate, br',
"Accept-Language": "zh-CN,zh;q=0.9", 'Accept-Language': 'zh-CN,zh;q=0.9',
"Referer": "https://servicewechat.com/", 'Connection': 'keep-alive',
"Connection": "keep-alive", 'Range': 'bytes=0-10'
"Range": "bytes=0-10" // Keep our range check
} }
} }
@@ -154,17 +155,15 @@ class SnsService {
status: res.statusCode, status: res.statusCode,
headers: { headers: {
'x-enc': res.headers['x-enc'], 'x-enc': res.headers['x-enc'],
'x-time': res.headers['x-time'],
'content-length': res.headers['content-length'], 'content-length': res.headers['content-length'],
'content-type': res.headers['content-type'] 'content-type': res.headers['content-type']
} }
}) })
req.destroy() // We only need headers req.destroy()
})
req.on('error', (e: any) => {
resolve({ success: false, error: e.message })
}) })
req.on('error', (e: any) => resolve({ success: false, error: e.message }))
req.end() req.end()
} catch (e: any) { } catch (e: any) {
resolve({ success: false, error: e.message }) resolve({ success: false, error: e.message })
@@ -172,12 +171,123 @@ class SnsService {
}) })
} }
private imageCache = new Map<string, string>() private parseSnsKey(key?: string | number): bigint | null {
if (key === undefined || key === null) return null
if (typeof key === 'number') return BigInt(Math.trunc(key))
const raw = String(key).trim()
if (!raw) return null
try {
return BigInt(raw)
} catch {
return null
}
}
async proxyImage(url: string): Promise<{ success: boolean; dataUrl?: string; error?: string }> { private resolveWeixinDllPath(): string | null {
// Check cache const candidates: string[] = []
if (this.imageCache.has(url)) { if (process.env.WEFLOW_WEIXIN_DLL) candidates.push(process.env.WEFLOW_WEIXIN_DLL)
return { success: true, dataUrl: this.imageCache.get(url) }
const weixinExe = process.env.WEFLOW_WEIXIN_EXE
if (weixinExe) {
const dir = weixinExe.replace(/\\Weixin\.exe$/i, '')
if (dir && dir !== weixinExe) candidates.push(join(dir, 'Weixin.dll'))
}
const programFiles = process.env.ProgramFiles || 'C:\\Program Files'
const localAppData = process.env.LOCALAPPDATA || ''
candidates.push(
join(programFiles, 'Tencent', 'Weixin', 'Weixin.dll'),
'D:\\weixindata\\Weixin\\Weixin.dll',
'C:\\Users\\16586\\Desktop\\sns\\Weixin.dll'
)
if (localAppData) candidates.push(join(localAppData, 'Tencent', 'xwechat', 'Weixin.dll'))
for (const p of candidates) {
if (p && existsSync(p)) return p
}
return null
}
private ensureNativeDecryptor(): boolean {
if (this.nativeDecryptInit) return this.nativeDecryptReady
this.nativeDecryptInit = true
try {
const dllPath = this.resolveWeixinDllPath()
if (!dllPath) {
this.nativeDecryptError = 'Weixin.dll not found, set WEFLOW_WEIXIN_DLL if needed'
return false
}
const koffi = require('koffi')
const kernel32 = koffi.load('kernel32.dll')
const getModuleHandleW = kernel32.func('void* __stdcall GetModuleHandleW(str16 lpModuleName)')
const weixinLib = koffi.load(dllPath)
let modulePtr = getModuleHandleW('Weixin.dll')
if (!modulePtr) modulePtr = getModuleHandleW(basename(dllPath))
if (!modulePtr) {
this.nativeDecryptError = `GetModuleHandleW 失败: ${dllPath}`
return false
}
const base = koffi.address(modulePtr) as bigint
const decryptAddr = base + WEIXIN_DLL_OFFSET
// Koffi requires an external pointer object (not raw Number/BigInt).
// Build a temporary uint64 box, decode it to void*, then decode function pointer.
const addrBox = new BigUint64Array(1)
addrBox[0] = decryptAddr
const decryptPtr = koffi.decode(addrBox, 'void *')
if (!decryptPtr) {
this.nativeDecryptError = `Decode function pointer failed: ${decryptAddr.toString(16)}`
return false
}
const decryptProto = koffi.proto('uint64 __fastcall SnsImageDecrypt(void* src, uint64 len, void* dst, uint64 key)')
this.nativeDecryptFn = koffi.decode(decryptPtr, decryptProto)
this.nativeKoffi = koffi
this.nativeWeixinLib = weixinLib
this.nativeDecryptReady = true
this.nativeDecryptDllPath = dllPath
console.info('[SNS] Native decrypt enabled:', this.nativeDecryptDllPath)
return true
} catch (e: any) {
this.nativeDecryptError = e?.message || String(e)
this.nativeDecryptReady = false
console.warn('[SNS] Native decrypt init failed:', this.nativeDecryptError)
return false
}
}
private decryptSnsEncryptedImage(data: Buffer, key: string | number): Buffer {
const parsedKey = this.parseSnsKey(key)
if (!parsedKey) return data
if (!this.ensureNativeDecryptor()) return data
const out = Buffer.allocUnsafe(data.length)
try {
this.nativeDecryptFn(
data,
BigInt(data.length),
out,
parsedKey
)
return out
} catch (e: any) {
this.nativeDecryptError = e?.message || String(e)
console.warn('[SNS] Native decrypt call failed:', this.nativeDecryptError)
return data
}
}
async proxyImage(url: string, key?: string | number): Promise<{ success: boolean; dataUrl?: string; error?: string }> {
if (!url) return { success: false, error: 'url 不能为空' }
const cacheKey = `${url}|${key ?? ''}`
if (this.imageCache.has(cacheKey)) {
return { success: true, dataUrl: this.imageCache.get(cacheKey) }
} }
return new Promise((resolve) => { return new Promise((resolve) => {
@@ -191,12 +301,11 @@ class SnsService {
path: urlObj.pathname + urlObj.search, path: urlObj.pathname + urlObj.search,
method: 'GET', method: 'GET',
headers: { 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", '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': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
"Accept-Encoding": "gzip, deflate, br", 'Accept-Encoding': 'gzip, deflate, br',
"Accept-Language": "zh-CN,zh;q=0.9", 'Accept-Language': 'zh-CN,zh;q=0.9',
"Referer": "https://servicewechat.com/", 'Connection': 'keep-alive'
"Connection": "keep-alive"
} }
} }
@@ -209,37 +318,28 @@ class SnsService {
const chunks: Buffer[] = [] const chunks: Buffer[] = []
let stream = res let stream = res
// Handle gzip compression
const encoding = res.headers['content-encoding'] const encoding = res.headers['content-encoding']
if (encoding === 'gzip') { if (encoding === 'gzip') stream = res.pipe(zlib.createGunzip())
stream = res.pipe(zlib.createGunzip()) else if (encoding === 'deflate') stream = res.pipe(zlib.createInflate())
} else if (encoding === 'deflate') { else if (encoding === 'br') stream = res.pipe(zlib.createBrotliDecompress())
stream = res.pipe(zlib.createInflate())
} else if (encoding === 'br') {
stream = res.pipe(zlib.createBrotliDecompress())
}
stream.on('data', (chunk: Buffer) => chunks.push(chunk)) stream.on('data', (chunk: Buffer) => chunks.push(chunk))
stream.on('end', () => { stream.on('end', () => {
const buffer = Buffer.concat(chunks) const raw = Buffer.concat(chunks)
const contentType = res.headers['content-type'] || 'image/jpeg' const xEnc = String(res.headers['x-enc'] || '').trim()
const base64 = buffer.toString('base64') const shouldDecrypt = xEnc === '1' && key !== undefined && key !== null && String(key).trim().length > 0
const dataUrl = `data:${contentType};base64,${base64}` const decoded = shouldDecrypt ? this.decryptSnsEncryptedImage(raw, key as string | number) : raw
// Cache const contentType = detectImageMime(decoded, (res.headers['content-type'] || 'image/jpeg') as string)
this.imageCache.set(url, dataUrl) const dataUrl = `data:${contentType};base64,${decoded.toString('base64')}`
this.imageCache.set(cacheKey, dataUrl)
resolve({ success: true, dataUrl }) resolve({ success: true, dataUrl })
}) })
stream.on('error', (e: any) => { stream.on('error', (e: any) => resolve({ success: false, error: e.message }))
resolve({ success: false, error: e.message })
})
})
req.on('error', (e: any) => {
resolve({ success: false, error: e.message })
}) })
req.on('error', (e: any) => resolve({ success: false, error: e.message }))
req.end() req.end()
} catch (e: any) { } catch (e: any) {
resolve({ success: false, error: e.message }) resolve({ success: false, error: e.message })
@@ -249,3 +349,6 @@ class SnsService {
} }
export const snsService = new SnsService() export const snsService = new SnsService()

View File

@@ -34,31 +34,51 @@ interface SnsPost {
rawXml?: string // 原始 XML 数据 rawXml?: string // 原始 XML 数据
} }
const MediaItem = ({ media, onPreview }: { media: any, onPreview: () => void }) => { const MediaItem = ({ media, onPreview }: { media: any; onPreview: (src: string) => void }) => {
const [error, setError] = useState(false); const [error, setError] = useState(false)
const { url, thumb, livePhoto } = media; const [resolvedSrc, setResolvedSrc] = useState<string>('')
const isLive = !!livePhoto; const { url, thumb, livePhoto } = media
const targetUrl = thumb || url; const isLive = !!livePhoto
const targetUrl = thumb || url
const handleDownload = (e: React.MouseEvent) => { useEffect(() => {
e.stopPropagation(); let cancelled = false
setError(false)
setResolvedSrc('')
let downloadUrl = url; const run = async () => {
let downloadKey = media.key || ''; try {
const result = await window.electronAPI.sns.proxyImage({
if (isLive && media.livePhoto) { url: targetUrl,
downloadUrl = media.livePhoto.url; key: media.key
downloadKey = media.livePhoto.key || ''; })
if (cancelled) return
if (result.success && result.dataUrl) {
setResolvedSrc(result.dataUrl)
} else {
setResolvedSrc(targetUrl)
}
} catch {
if (!cancelled) setResolvedSrc(targetUrl)
}
} }
// TODO: 调用后端下载服务 run()
// window.electronAPI.sns.download(downloadUrl, downloadKey); return () => { cancelled = true }
}; }, [targetUrl, media.key])
const handleDownload = (e: React.MouseEvent) => {
e.stopPropagation()
// TODO: call backend download service
}
const displaySrc = resolvedSrc || targetUrl
const previewSrc = resolvedSrc || url || targetUrl
return ( return (
<div className={`media-item ${error ? 'error' : ''}`} onClick={onPreview}> <div className={`media-item ${error ? 'error' : ''}`} onClick={() => onPreview(previewSrc)}>
<img <img
src={targetUrl} src={displaySrc}
alt="" alt=""
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
loading="lazy" loading="lazy"
@@ -69,12 +89,12 @@ const MediaItem = ({ media, onPreview }: { media: any, onPreview: () => void })
<LivePhotoIcon size={16} className="live-icon" /> <LivePhotoIcon size={16} className="live-icon" />
</div> </div>
)} )}
<button className="download-btn-overlay" onClick={handleDownload} title="下载原图"> <button className="download-btn-overlay" onClick={handleDownload} title="Download original">
<Download size={14} /> <Download size={14} />
</button> </button>
</div> </div>
); )
}; }
interface Contact { interface Contact {
username: string username: string
@@ -471,7 +491,7 @@ export default function SnsPage() {
) : post.media.length > 0 && ( ) : post.media.length > 0 && (
<div className={`post-media-grid media-count-${Math.min(post.media.length, 9)}`}> <div className={`post-media-grid media-count-${Math.min(post.media.length, 9)}`}>
{post.media.map((m, idx) => ( {post.media.map((m, idx) => (
<MediaItem key={idx} media={m} onPreview={() => setPreviewImage(m.url)} /> <MediaItem key={idx} media={m} onPreview={(src) => setPreviewImage(src)} />
))} ))}
</div> </div>
)} )}

View File

@@ -477,7 +477,7 @@ export interface ElectronAPI {
error?: string error?: string
}> }>
debugResource: (url: string) => Promise<{ success: boolean; status?: number; headers?: any; error?: string }> debugResource: (url: string) => Promise<{ success: boolean; status?: number; headers?: any; error?: string }>
proxyImage: (url: string) => Promise<{ success: boolean; dataUrl?: string; error?: string }> proxyImage: (payload: { url: string; key?: string | number }) => Promise<{ success: boolean; dataUrl?: string; error?: string }>
} }
llama: { llama: {
loadModel: (modelPath: string) => Promise<boolean> loadModel: (modelPath: string) => Promise<boolean>