Files
WeFlow/electron/services/snsService.ts
2026-02-01 22:56:43 +08:00

252 lines
9.7 KiB
TypeScript

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
nickname: string
avatarUrl?: string
createTime: number
contentDesc: string
type?: number
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
constructor() {
const config = new ConfigService()
this.contactCache = new ContactCacheService(config.get('cachePath') as 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)
if (result.success && result.timeline) {
const enrichedTimeline = result.timeline.map((post: any, index: number) => {
const contact = this.contactCache.get(post.username)
// 修复媒体 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,
avatarUrl: contact?.avatarUrl,
nickname: post.nickname || contact?.displayName || post.username,
media: fixedMedia
}
})
return { ...result, timeline: enrichedTimeline }
}
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<string, string>()
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()