mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-21 23:24:06 +08:00
2230 lines
99 KiB
TypeScript
2230 lines
99 KiB
TypeScript
import { wcdbService } from './wcdbService'
|
||
import { ConfigService } from './config'
|
||
import { ContactCacheService } from './contactCacheService'
|
||
import { existsSync, mkdirSync } from 'fs'
|
||
import { readFile, writeFile, mkdir } from 'fs/promises'
|
||
import { basename, join } from 'path'
|
||
import crypto from 'crypto'
|
||
import { WasmService } from './wasmService'
|
||
import zlib from 'zlib'
|
||
|
||
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
|
||
tid?: string // 数据库主键(雪花 ID),用于精确删除
|
||
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; emojis?: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }[] }[]
|
||
rawXml?: string
|
||
linkTitle?: string
|
||
linkUrl?: string
|
||
}
|
||
|
||
interface SnsContactIdentity {
|
||
username: string
|
||
wxid: string
|
||
alias?: string
|
||
wechatId?: string
|
||
remark?: string
|
||
nickName?: string
|
||
displayName: string
|
||
}
|
||
|
||
interface ParsedLikeUser {
|
||
username?: string
|
||
nickname?: string
|
||
}
|
||
|
||
interface ParsedCommentItem {
|
||
id: string
|
||
nickname: string
|
||
username?: string
|
||
content: string
|
||
refCommentId: string
|
||
refUsername?: string
|
||
refNickname?: string
|
||
emojis?: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }[]
|
||
}
|
||
|
||
interface ArkmeLikeDetail {
|
||
nickname: string
|
||
username?: string
|
||
wxid?: string
|
||
alias?: string
|
||
wechatId?: string
|
||
remark?: string
|
||
nickName?: string
|
||
displayName: string
|
||
source: 'xml' | 'legacy'
|
||
}
|
||
|
||
interface ArkmeCommentDetail {
|
||
id: string
|
||
nickname: string
|
||
username?: string
|
||
wxid?: string
|
||
alias?: string
|
||
wechatId?: string
|
||
remark?: string
|
||
nickName?: string
|
||
displayName: string
|
||
content: string
|
||
refCommentId: string
|
||
refNickname?: string
|
||
refUsername?: string
|
||
refWxid?: string
|
||
refAlias?: string
|
||
refWechatId?: string
|
||
refRemark?: string
|
||
refNickName?: string
|
||
refDisplayName?: string
|
||
emojis?: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }[]
|
||
source: 'xml' | 'legacy'
|
||
}
|
||
|
||
|
||
|
||
const fixSnsUrl = (url: string, token?: string, isVideo: boolean = false) => {
|
||
if (!url) return url
|
||
|
||
let fixedUrl = url.replace('http://', 'https://')
|
||
|
||
// 只有非视频(即图片)才需要处理 /150 变 /0
|
||
if (!isVideo) {
|
||
fixedUrl = fixedUrl.replace(/\/150($|\?)/, '/0$1')
|
||
}
|
||
|
||
if (!token || fixedUrl.includes('token=')) return fixedUrl
|
||
|
||
// 根据用户要求,视频链接组合方式为: BASE_URL + "?" + "token=" + token + "&idx=1" + 原有参数
|
||
if (isVideo) {
|
||
const urlParts = fixedUrl.split('?')
|
||
const baseUrl = urlParts[0]
|
||
const existingParams = urlParts[1] ? `&${urlParts[1]}` : ''
|
||
return `${baseUrl}?token=${token}&idx=1${existingParams}`
|
||
}
|
||
|
||
const connector = fixedUrl.includes('?') ? '&' : '?'
|
||
return `${fixedUrl}${connector}token=${token}&idx=1`
|
||
}
|
||
|
||
const detectImageMime = (buf: Buffer, fallback: string = 'image/jpeg') => {
|
||
if (!buf || buf.length < 4) return fallback
|
||
|
||
// JPEG
|
||
if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return 'image/jpeg'
|
||
|
||
// PNG
|
||
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'
|
||
|
||
// GIF
|
||
if (buf.length >= 6) {
|
||
const sig = buf.subarray(0, 6).toString('ascii')
|
||
if (sig === 'GIF87a' || sig === 'GIF89a') return 'image/gif'
|
||
}
|
||
|
||
// WebP
|
||
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'
|
||
|
||
// BMP
|
||
if (buf[0] === 0x42 && buf[1] === 0x4d) return 'image/bmp'
|
||
|
||
// MP4: 00 00 00 18 / 20 / ... + 'ftyp'
|
||
if (buf.length > 8 && buf[4] === 0x66 && buf[5] === 0x74 && buf[6] === 0x79 && buf[7] === 0x70) return 'video/mp4'
|
||
|
||
// Fallback logic for video
|
||
if (fallback.includes('video') || fallback.includes('mp4')) return 'video/mp4'
|
||
|
||
return fallback
|
||
}
|
||
|
||
export const isVideoUrl = (url: string) => {
|
||
if (!url) return false
|
||
// 排除 vweixinthumb 域名 (缩略图)
|
||
if (url.includes('vweixinthumb')) return false
|
||
return url.includes('snsvideodownload') || url.includes('video') || url.includes('.mp4')
|
||
}
|
||
|
||
import { Isaac64 } from './isaac64'
|
||
|
||
const extractVideoKey = (xml: string): string | undefined => {
|
||
if (!xml) return undefined
|
||
// 匹配 <enc key="2105122989" ... /> 或 <enc key="2105122989">
|
||
const match = xml.match(/<enc\s+key="(\d+)"/i)
|
||
return match ? match[1] : undefined
|
||
}
|
||
|
||
/**
|
||
* 从 XML 中解析评论信息(含表情包、回复关系)
|
||
*/
|
||
function parseCommentsFromXml(xml: string): ParsedCommentItem[] {
|
||
if (!xml) return []
|
||
|
||
type CommentItem = {
|
||
id: string; nickname: string; username?: string; content: string
|
||
refCommentId: string; refUsername?: string; refNickname?: string
|
||
emojis?: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }[]
|
||
}
|
||
const comments: CommentItem[] = []
|
||
|
||
try {
|
||
// 支持多种标签格式
|
||
let listMatch = xml.match(/<CommentUserList>([\s\S]*?)<\/CommentUserList>/i)
|
||
if (!listMatch) listMatch = xml.match(/<commentUserList>([\s\S]*?)<\/commentUserList>/i)
|
||
if (!listMatch) listMatch = xml.match(/<commentList>([\s\S]*?)<\/commentList>/i)
|
||
if (!listMatch) listMatch = xml.match(/<comment_user_list>([\s\S]*?)<\/comment_user_list>/i)
|
||
if (!listMatch) return comments
|
||
|
||
const listXml = listMatch[1]
|
||
const itemRegex = /<(?:CommentUser|commentUser|comment|user_comment)>([\s\S]*?)<\/(?:CommentUser|commentUser|comment|user_comment)>/gi
|
||
let m: RegExpExecArray | null
|
||
|
||
while ((m = itemRegex.exec(listXml)) !== null) {
|
||
const c = m[1]
|
||
|
||
const idMatch = c.match(/<(?:cmtid|commentId|comment_id|id)>([^<]*)<\/(?:cmtid|commentId|comment_id|id)>/i)
|
||
const usernameMatch = c.match(/<username>([^<]*)<\/username>/i)
|
||
let nicknameMatch = c.match(/<nickname>([^<]*)<\/nickname>/i)
|
||
if (!nicknameMatch) nicknameMatch = c.match(/<nickName>([^<]*)<\/nickName>/i)
|
||
const contentMatch = c.match(/<content>([^<]*)<\/content>/i)
|
||
const refIdMatch = c.match(/<(?:refCommentId|replyCommentId|ref_comment_id)>([^<]*)<\/(?:refCommentId|replyCommentId|ref_comment_id)>/i)
|
||
const refNickMatch = c.match(/<(?:refNickname|refNickName|replyNickname)>([^<]*)<\/(?:refNickname|refNickName|replyNickname)>/i)
|
||
const refUserMatch = c.match(/<ref_username>([^<]*)<\/ref_username>/i)
|
||
|
||
// 解析表情包
|
||
const emojis: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }[] = []
|
||
const emojiRegex = /<emojiinfo>([\s\S]*?)<\/emojiinfo>/gi
|
||
let em: RegExpExecArray | null
|
||
while ((em = emojiRegex.exec(c)) !== null) {
|
||
const ex = em[1]
|
||
const externUrl = ex.match(/<extern_url>([^<]*)<\/extern_url>/i)
|
||
const cdnUrl = ex.match(/<cdn_url>([^<]*)<\/cdn_url>/i)
|
||
const plainUrl = ex.match(/<url>([^<]*)<\/url>/i)
|
||
const urlMatch = externUrl || cdnUrl || plainUrl
|
||
const md5Match = ex.match(/<md5>([^<]*)<\/md5>/i)
|
||
const wMatch = ex.match(/<width>([^<]*)<\/width>/i)
|
||
const hMatch = ex.match(/<height>([^<]*)<\/height>/i)
|
||
const encMatch = ex.match(/<encrypt_url>([^<]*)<\/encrypt_url>/i)
|
||
const aesMatch = ex.match(/<aes_key>([^<]*)<\/aes_key>/i)
|
||
|
||
const url = urlMatch ? urlMatch[1].trim().replace(/&/g, '&') : ''
|
||
const encryptUrl = encMatch ? encMatch[1].trim().replace(/&/g, '&') : undefined
|
||
const aesKey = aesMatch ? aesMatch[1].trim() : undefined
|
||
|
||
if (url || encryptUrl) {
|
||
emojis.push({
|
||
url,
|
||
md5: md5Match ? md5Match[1].trim() : '',
|
||
width: wMatch ? parseInt(wMatch[1]) : 0,
|
||
height: hMatch ? parseInt(hMatch[1]) : 0,
|
||
encryptUrl,
|
||
aesKey
|
||
})
|
||
}
|
||
}
|
||
|
||
if (nicknameMatch && (contentMatch || emojis.length > 0)) {
|
||
const refId = refIdMatch ? refIdMatch[1].trim() : ''
|
||
comments.push({
|
||
id: idMatch ? idMatch[1].trim() : `cmt_${Date.now()}_${Math.random()}`,
|
||
nickname: nicknameMatch[1].trim(),
|
||
username: usernameMatch ? usernameMatch[1].trim() : undefined,
|
||
content: contentMatch ? contentMatch[1].trim() : '',
|
||
refCommentId: refId === '0' ? '' : refId,
|
||
refUsername: refUserMatch ? refUserMatch[1].trim() : undefined,
|
||
refNickname: refNickMatch ? refNickMatch[1].trim() : undefined,
|
||
emojis: emojis.length > 0 ? emojis : undefined
|
||
})
|
||
}
|
||
}
|
||
|
||
// 二次解析:通过 refUsername 补全 refNickname
|
||
const userMap = new Map<string, string>()
|
||
for (const c of comments) {
|
||
if (c.username && c.nickname) userMap.set(c.username, c.nickname)
|
||
}
|
||
for (const c of comments) {
|
||
if (!c.refNickname && c.refUsername && c.refCommentId) {
|
||
c.refNickname = userMap.get(c.refUsername)
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error('[SnsService] parseCommentsFromXml 失败:', e)
|
||
}
|
||
|
||
return comments
|
||
}
|
||
|
||
class SnsService {
|
||
private configService: ConfigService
|
||
private contactCache: ContactCacheService
|
||
private imageCache = new Map<string, string>()
|
||
private exportStatsCache: { totalPosts: number; totalFriends: number; myPosts: number | null; updatedAt: number } | null = null
|
||
private userPostCountsCache: { counts: Record<string, number>; updatedAt: number } | null = null
|
||
private readonly exportStatsCacheTtlMs = 5 * 60 * 1000
|
||
private readonly userPostCountsCacheTtlMs = 5 * 60 * 1000
|
||
private lastTimelineFallbackAt = 0
|
||
private readonly timelineFallbackCooldownMs = 3 * 60 * 1000
|
||
|
||
constructor() {
|
||
this.configService = new ConfigService()
|
||
this.contactCache = new ContactCacheService(this.configService.get('cachePath') as string)
|
||
}
|
||
|
||
private toOptionalString(value: unknown): string | undefined {
|
||
if (typeof value !== 'string') return undefined
|
||
const trimmed = value.trim()
|
||
return trimmed.length > 0 ? trimmed : undefined
|
||
}
|
||
|
||
private async resolveContactIdentity(
|
||
username: string,
|
||
identityCache: Map<string, Promise<SnsContactIdentity | null>>
|
||
): Promise<SnsContactIdentity | null> {
|
||
const normalized = String(username || '').trim()
|
||
if (!normalized) return null
|
||
|
||
let pending = identityCache.get(normalized)
|
||
if (!pending) {
|
||
pending = (async () => {
|
||
const cached = this.contactCache.get(normalized)
|
||
let alias: string | undefined
|
||
let remark: string | undefined
|
||
let nickName: string | undefined
|
||
|
||
try {
|
||
const contactResult = await wcdbService.getContact(normalized)
|
||
if (contactResult.success && contactResult.contact) {
|
||
const contact = contactResult.contact
|
||
alias = this.toOptionalString(contact.alias ?? contact.Alias)
|
||
remark = this.toOptionalString(contact.remark ?? contact.Remark)
|
||
nickName = this.toOptionalString(contact.nickName ?? contact.nick_name ?? contact.nickname ?? contact.NickName)
|
||
}
|
||
} catch {
|
||
// 联系人补全失败不影响导出
|
||
}
|
||
|
||
const displayName = remark || nickName || alias || cached?.displayName || normalized
|
||
return {
|
||
username: normalized,
|
||
wxid: normalized,
|
||
alias,
|
||
wechatId: alias,
|
||
remark,
|
||
nickName,
|
||
displayName
|
||
}
|
||
})()
|
||
identityCache.set(normalized, pending)
|
||
}
|
||
|
||
return pending
|
||
}
|
||
|
||
private parseLikeUsersFromXml(xml: string): ParsedLikeUser[] {
|
||
if (!xml) return []
|
||
const likes: ParsedLikeUser[] = []
|
||
try {
|
||
let likeListMatch = xml.match(/<LikeUserList>([\s\S]*?)<\/LikeUserList>/i)
|
||
if (!likeListMatch) likeListMatch = xml.match(/<likeUserList>([\s\S]*?)<\/likeUserList>/i)
|
||
if (!likeListMatch) likeListMatch = xml.match(/<likeList>([\s\S]*?)<\/likeList>/i)
|
||
if (!likeListMatch) likeListMatch = xml.match(/<like_user_list>([\s\S]*?)<\/like_user_list>/i)
|
||
if (!likeListMatch) return likes
|
||
|
||
const likeUserRegex = /<(?:LikeUser|likeUser|user_comment)>([\s\S]*?)<\/(?:LikeUser|likeUser|user_comment)>/gi
|
||
let m: RegExpExecArray | null
|
||
while ((m = likeUserRegex.exec(likeListMatch[1])) !== null) {
|
||
const block = m[1]
|
||
const username = this.toOptionalString(block.match(/<username>([^<]*)<\/username>/i)?.[1])
|
||
const nickname = this.toOptionalString(
|
||
block.match(/<nickname>([^<]*)<\/nickname>/i)?.[1]
|
||
|| block.match(/<nickName>([^<]*)<\/nickName>/i)?.[1]
|
||
)
|
||
if (username || nickname) {
|
||
likes.push({ username, nickname })
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error('[SnsService] 解析点赞用户失败:', e)
|
||
}
|
||
return likes
|
||
}
|
||
|
||
private async buildArkmeInteractionDetails(
|
||
post: SnsPost,
|
||
identityCache: Map<string, Promise<SnsContactIdentity | null>>
|
||
): Promise<{ likesDetail: ArkmeLikeDetail[]; commentsDetail: ArkmeCommentDetail[] }> {
|
||
const xmlLikes = this.parseLikeUsersFromXml(post.rawXml || '')
|
||
const likeCandidates: ParsedLikeUser[] = xmlLikes.length > 0
|
||
? xmlLikes
|
||
: (post.likes || []).map((nickname) => ({ nickname }))
|
||
const likeSource: 'xml' | 'legacy' = xmlLikes.length > 0 ? 'xml' : 'legacy'
|
||
const likesDetail: ArkmeLikeDetail[] = []
|
||
const likeSeen = new Set<string>()
|
||
|
||
for (const like of likeCandidates) {
|
||
const identity = like.username
|
||
? await this.resolveContactIdentity(like.username, identityCache)
|
||
: null
|
||
const nickname = like.nickname || identity?.displayName || like.username || ''
|
||
const username = identity?.username || like.username
|
||
const key = `${username || ''}|${nickname}`
|
||
if (likeSeen.has(key)) continue
|
||
likeSeen.add(key)
|
||
likesDetail.push({
|
||
nickname,
|
||
username,
|
||
wxid: username,
|
||
alias: identity?.alias,
|
||
wechatId: identity?.wechatId,
|
||
remark: identity?.remark,
|
||
nickName: identity?.nickName,
|
||
displayName: identity?.displayName || nickname || username || '',
|
||
source: likeSource
|
||
})
|
||
}
|
||
|
||
const xmlComments = parseCommentsFromXml(post.rawXml || '')
|
||
const commentMap = new Map<string, SnsPost['comments'][number]>()
|
||
for (const comment of post.comments || []) {
|
||
if (comment.id) commentMap.set(comment.id, comment)
|
||
}
|
||
|
||
const commentsBase: ParsedCommentItem[] = xmlComments.length > 0
|
||
? xmlComments.map((comment) => {
|
||
const fallback = comment.id ? commentMap.get(comment.id) : undefined
|
||
return {
|
||
id: comment.id || fallback?.id || '',
|
||
nickname: comment.nickname || fallback?.nickname || '',
|
||
username: comment.username,
|
||
content: comment.content || fallback?.content || '',
|
||
refCommentId: comment.refCommentId || fallback?.refCommentId || '',
|
||
refUsername: comment.refUsername,
|
||
refNickname: comment.refNickname || fallback?.refNickname,
|
||
emojis: comment.emojis && comment.emojis.length > 0 ? comment.emojis : fallback?.emojis
|
||
}
|
||
})
|
||
: (post.comments || []).map((comment) => ({
|
||
id: comment.id || '',
|
||
nickname: comment.nickname || '',
|
||
content: comment.content || '',
|
||
refCommentId: comment.refCommentId || '',
|
||
refNickname: comment.refNickname,
|
||
emojis: comment.emojis
|
||
}))
|
||
|
||
if (xmlComments.length > 0) {
|
||
const mappedIds = new Set(commentsBase.map((comment) => comment.id).filter(Boolean))
|
||
for (const comment of post.comments || []) {
|
||
if (comment.id && mappedIds.has(comment.id)) continue
|
||
commentsBase.push({
|
||
id: comment.id || '',
|
||
nickname: comment.nickname || '',
|
||
content: comment.content || '',
|
||
refCommentId: comment.refCommentId || '',
|
||
refNickname: comment.refNickname,
|
||
emojis: comment.emojis
|
||
})
|
||
}
|
||
}
|
||
|
||
const commentSource: 'xml' | 'legacy' = xmlComments.length > 0 ? 'xml' : 'legacy'
|
||
const commentsDetail: ArkmeCommentDetail[] = []
|
||
|
||
for (const comment of commentsBase) {
|
||
const actor = comment.username
|
||
? await this.resolveContactIdentity(comment.username, identityCache)
|
||
: null
|
||
const refActor = comment.refUsername
|
||
? await this.resolveContactIdentity(comment.refUsername, identityCache)
|
||
: null
|
||
const nickname = comment.nickname || actor?.displayName || comment.username || ''
|
||
const username = actor?.username || comment.username
|
||
const refUsername = refActor?.username || comment.refUsername
|
||
commentsDetail.push({
|
||
id: comment.id || '',
|
||
nickname,
|
||
username,
|
||
wxid: username,
|
||
alias: actor?.alias,
|
||
wechatId: actor?.wechatId,
|
||
remark: actor?.remark,
|
||
nickName: actor?.nickName,
|
||
displayName: actor?.displayName || nickname || username || '',
|
||
content: comment.content || '',
|
||
refCommentId: comment.refCommentId || '',
|
||
refNickname: comment.refNickname || refActor?.displayName,
|
||
refUsername,
|
||
refWxid: refUsername,
|
||
refAlias: refActor?.alias,
|
||
refWechatId: refActor?.wechatId,
|
||
refRemark: refActor?.remark,
|
||
refNickName: refActor?.nickName,
|
||
refDisplayName: refActor?.displayName,
|
||
emojis: comment.emojis,
|
||
source: commentSource
|
||
})
|
||
}
|
||
|
||
return { likesDetail, commentsDetail }
|
||
}
|
||
|
||
private parseCountValue(row: any): number {
|
||
if (!row || typeof row !== 'object') return 0
|
||
const raw = row.total ?? row.count ?? row.cnt ?? Object.values(row)[0]
|
||
const num = Number(raw)
|
||
return Number.isFinite(num) && num > 0 ? Math.floor(num) : 0
|
||
}
|
||
|
||
private pickTimelineUsername(post: any): string {
|
||
const raw = post?.username ?? post?.user_name ?? post?.userName ?? ''
|
||
if (typeof raw !== 'string') return ''
|
||
return raw.trim()
|
||
}
|
||
|
||
private async getExportStatsFromTimeline(myWxid?: string): Promise<{ totalPosts: number; totalFriends: number; myPosts: number | null }> {
|
||
const pageSize = 500
|
||
const uniqueUsers = new Set<string>()
|
||
let totalPosts = 0
|
||
let myPosts = 0
|
||
let offset = 0
|
||
const normalizedMyWxid = this.toOptionalString(myWxid)
|
||
|
||
for (let round = 0; round < 2000; round++) {
|
||
const result = await wcdbService.getSnsTimeline(pageSize, offset, undefined, undefined, 0, 0)
|
||
if (!result.success || !Array.isArray(result.timeline)) {
|
||
throw new Error(result.error || '获取朋友圈统计失败')
|
||
}
|
||
|
||
const rows = result.timeline
|
||
if (rows.length === 0) break
|
||
|
||
totalPosts += rows.length
|
||
for (const row of rows) {
|
||
const username = this.pickTimelineUsername(row)
|
||
if (username) uniqueUsers.add(username)
|
||
if (normalizedMyWxid && username === normalizedMyWxid) myPosts += 1
|
||
}
|
||
|
||
if (rows.length < pageSize) break
|
||
offset += rows.length
|
||
}
|
||
|
||
return {
|
||
totalPosts,
|
||
totalFriends: uniqueUsers.size,
|
||
myPosts: normalizedMyWxid ? myPosts : null
|
||
}
|
||
}
|
||
|
||
private parseLikesFromXml(xml: string): string[] {
|
||
if (!xml) return []
|
||
const likes: string[] = []
|
||
try {
|
||
let likeListMatch = xml.match(/<LikeUserList>([\s\S]*?)<\/LikeUserList>/i)
|
||
if (!likeListMatch) likeListMatch = xml.match(/<likeUserList>([\s\S]*?)<\/likeUserList>/i)
|
||
if (!likeListMatch) likeListMatch = xml.match(/<likeList>([\s\S]*?)<\/likeList>/i)
|
||
if (!likeListMatch) likeListMatch = xml.match(/<like_user_list>([\s\S]*?)<\/like_user_list>/i)
|
||
if (!likeListMatch) return likes
|
||
|
||
const likeUserRegex = /<(?:LikeUser|likeUser|user_comment)>([\s\S]*?)<\/(?:LikeUser|likeUser|user_comment)>/gi
|
||
let m: RegExpExecArray | null
|
||
while ((m = likeUserRegex.exec(likeListMatch[1])) !== null) {
|
||
let nick = m[1].match(/<nickname>([^<]*)<\/nickname>/i)
|
||
if (!nick) nick = m[1].match(/<nickName>([^<]*)<\/nickName>/i)
|
||
if (nick) likes.push(nick[1].trim())
|
||
}
|
||
} catch (e) {
|
||
console.error('[SnsService] 解析点赞失败:', e)
|
||
}
|
||
return likes
|
||
}
|
||
|
||
private parseMediaFromXml(xml: string): { media: SnsMedia[]; videoKey?: string } {
|
||
if (!xml) return { media: [] }
|
||
const media: SnsMedia[] = []
|
||
let videoKey: string | undefined
|
||
try {
|
||
const encMatch = xml.match(/<enc\s+key="(\d+)"/i)
|
||
if (encMatch) videoKey = encMatch[1]
|
||
|
||
const mediaRegex = /<media>([\s\S]*?)<\/media>/gi
|
||
let mediaMatch: RegExpExecArray | null
|
||
while ((mediaMatch = mediaRegex.exec(xml)) !== null) {
|
||
const mx = mediaMatch[1]
|
||
const urlMatch = mx.match(/<url[^>]*>([^<]+)<\/url>/i)
|
||
const urlTagMatch = mx.match(/<url([^>]*)>/i)
|
||
const thumbMatch = mx.match(/<thumb[^>]*>([^<]+)<\/thumb>/i)
|
||
const thumbTagMatch = mx.match(/<thumb([^>]*)>/i)
|
||
|
||
let urlToken: string | undefined, urlKey: string | undefined
|
||
let urlMd5: string | undefined, urlEncIdx: string | undefined
|
||
if (urlTagMatch?.[1]) {
|
||
const a = urlTagMatch[1]
|
||
urlToken = a.match(/token="([^"]+)"/i)?.[1]
|
||
urlKey = a.match(/key="([^"]+)"/i)?.[1]
|
||
urlMd5 = a.match(/md5="([^"]+)"/i)?.[1]
|
||
urlEncIdx = a.match(/enc_idx="([^"]+)"/i)?.[1]
|
||
}
|
||
let thumbToken: string | undefined, thumbKey: string | undefined, thumbEncIdx: string | undefined
|
||
if (thumbTagMatch?.[1]) {
|
||
const a = thumbTagMatch[1]
|
||
thumbToken = a.match(/token="([^"]+)"/i)?.[1]
|
||
thumbKey = a.match(/key="([^"]+)"/i)?.[1]
|
||
thumbEncIdx = a.match(/enc_idx="([^"]+)"/i)?.[1]
|
||
}
|
||
|
||
const item: SnsMedia = {
|
||
url: urlMatch ? urlMatch[1].trim() : '',
|
||
thumb: thumbMatch ? thumbMatch[1].trim() : '',
|
||
token: urlToken || thumbToken,
|
||
key: urlKey || thumbKey,
|
||
md5: urlMd5,
|
||
encIdx: urlEncIdx || thumbEncIdx
|
||
}
|
||
|
||
const livePhotoMatch = mx.match(/<livePhoto>([\s\S]*?)<\/livePhoto>/i)
|
||
if (livePhotoMatch) {
|
||
const lx = livePhotoMatch[1]
|
||
const lpUrl = lx.match(/<url[^>]*>([^<]+)<\/url>/i)
|
||
const lpUrlTag = lx.match(/<url([^>]*)>/i)
|
||
const lpThumb = lx.match(/<thumb[^>]*>([^<]+)<\/thumb>/i)
|
||
const lpThumbTag = lx.match(/<thumb([^>]*)>/i)
|
||
let lpToken: string | undefined, lpKey: string | undefined, lpEncIdx: string | undefined
|
||
if (lpUrlTag?.[1]) {
|
||
const a = lpUrlTag[1]
|
||
lpToken = a.match(/token="([^"]+)"/i)?.[1]
|
||
lpKey = a.match(/key="([^"]+)"/i)?.[1]
|
||
lpEncIdx = a.match(/enc_idx="([^"]+)"/i)?.[1]
|
||
}
|
||
if (!lpToken && lpThumbTag?.[1]) lpToken = lpThumbTag[1].match(/token="([^"]+)"/i)?.[1]
|
||
if (!lpKey && lpThumbTag?.[1]) lpKey = lpThumbTag[1].match(/key="([^"]+)"/i)?.[1]
|
||
item.livePhoto = {
|
||
url: lpUrl ? lpUrl[1].trim() : '',
|
||
thumb: lpThumb ? lpThumb[1].trim() : '',
|
||
token: lpToken,
|
||
key: lpKey,
|
||
encIdx: lpEncIdx
|
||
}
|
||
}
|
||
media.push(item)
|
||
}
|
||
} catch (e) {
|
||
console.error('[SnsService] 解析媒体 XML 失败:', e)
|
||
}
|
||
return { media, videoKey }
|
||
}
|
||
|
||
private getSnsCacheDir(): string {
|
||
const cachePath = this.configService.getCacheBasePath()
|
||
const snsCacheDir = join(cachePath, 'sns_cache')
|
||
if (!existsSync(snsCacheDir)) {
|
||
mkdirSync(snsCacheDir, { recursive: true })
|
||
}
|
||
return snsCacheDir
|
||
}
|
||
|
||
private getCacheFilePath(url: string): string {
|
||
const hash = crypto.createHash('md5').update(url).digest('hex')
|
||
const ext = isVideoUrl(url) ? '.mp4' : '.jpg'
|
||
return join(this.getSnsCacheDir(), `${hash}${ext}`)
|
||
}
|
||
|
||
async getSnsUsernames(): Promise<{ success: boolean; usernames?: string[]; error?: string }> {
|
||
const collect = (rows?: any[]): string[] => {
|
||
if (!Array.isArray(rows)) return []
|
||
const usernames: string[] = []
|
||
for (const row of rows) {
|
||
const raw = row?.user_name ?? row?.userName ?? row?.username ?? Object.values(row || {})[0]
|
||
const username = typeof raw === 'string' ? raw.trim() : String(raw || '').trim()
|
||
if (username) usernames.push(username)
|
||
}
|
||
return usernames
|
||
}
|
||
|
||
const primary = await wcdbService.execQuery(
|
||
'sns',
|
||
null,
|
||
"SELECT DISTINCT user_name FROM SnsTimeLine WHERE user_name IS NOT NULL AND user_name <> ''"
|
||
)
|
||
const fallback = await wcdbService.execQuery(
|
||
'sns',
|
||
null,
|
||
"SELECT DISTINCT userName FROM SnsTimeLine WHERE userName IS NOT NULL AND userName <> ''"
|
||
)
|
||
|
||
const merged = Array.from(new Set([
|
||
...collect(primary.rows),
|
||
...collect(fallback.rows)
|
||
]))
|
||
|
||
// 任一查询成功且拿到用户名即视为成功,避免因为列名差异导致误判为空。
|
||
if (merged.length > 0) {
|
||
return { success: true, usernames: merged }
|
||
}
|
||
|
||
// 两条查询都成功但无数据,说明确实没有朋友圈发布者。
|
||
if (primary.success || fallback.success) {
|
||
return { success: true, usernames: [] }
|
||
}
|
||
|
||
return { success: false, error: primary.error || fallback.error || '获取朋友圈联系人失败' }
|
||
}
|
||
|
||
private async getExportStatsFromTableCount(myWxid?: string): Promise<{ totalPosts: number; totalFriends: number; myPosts: number | null }> {
|
||
let totalPosts = 0
|
||
let totalFriends = 0
|
||
let myPosts: number | null = null
|
||
|
||
const postCountResult = await wcdbService.execQuery('sns', null, 'SELECT COUNT(1) AS total FROM SnsTimeLine')
|
||
if (postCountResult.success && postCountResult.rows && postCountResult.rows.length > 0) {
|
||
totalPosts = this.parseCountValue(postCountResult.rows[0])
|
||
}
|
||
|
||
if (totalPosts > 0) {
|
||
const friendCountPrimary = await wcdbService.execQuery(
|
||
'sns',
|
||
null,
|
||
"SELECT COUNT(DISTINCT user_name) AS total FROM SnsTimeLine WHERE user_name IS NOT NULL AND user_name <> ''"
|
||
)
|
||
if (friendCountPrimary.success && friendCountPrimary.rows && friendCountPrimary.rows.length > 0) {
|
||
totalFriends = this.parseCountValue(friendCountPrimary.rows[0])
|
||
} else {
|
||
const friendCountFallback = await wcdbService.execQuery(
|
||
'sns',
|
||
null,
|
||
"SELECT COUNT(DISTINCT userName) AS total FROM SnsTimeLine WHERE userName IS NOT NULL AND userName <> ''"
|
||
)
|
||
if (friendCountFallback.success && friendCountFallback.rows && friendCountFallback.rows.length > 0) {
|
||
totalFriends = this.parseCountValue(friendCountFallback.rows[0])
|
||
}
|
||
}
|
||
}
|
||
|
||
const normalizedMyWxid = this.toOptionalString(myWxid)
|
||
if (normalizedMyWxid) {
|
||
const myPostPrimary = await wcdbService.execQuery(
|
||
'sns',
|
||
null,
|
||
"SELECT COUNT(1) AS total FROM SnsTimeLine WHERE user_name = ?",
|
||
[normalizedMyWxid]
|
||
)
|
||
if (myPostPrimary.success && myPostPrimary.rows && myPostPrimary.rows.length > 0) {
|
||
myPosts = this.parseCountValue(myPostPrimary.rows[0])
|
||
} else {
|
||
const myPostFallback = await wcdbService.execQuery(
|
||
'sns',
|
||
null,
|
||
"SELECT COUNT(1) AS total FROM SnsTimeLine WHERE userName = ?",
|
||
[normalizedMyWxid]
|
||
)
|
||
if (myPostFallback.success && myPostFallback.rows && myPostFallback.rows.length > 0) {
|
||
myPosts = this.parseCountValue(myPostFallback.rows[0])
|
||
}
|
||
}
|
||
}
|
||
|
||
return { totalPosts, totalFriends, myPosts }
|
||
}
|
||
|
||
async getExportStats(options?: {
|
||
allowTimelineFallback?: boolean
|
||
preferCache?: boolean
|
||
}): Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number; myPosts: number | null }; error?: string }> {
|
||
const allowTimelineFallback = options?.allowTimelineFallback ?? true
|
||
const preferCache = options?.preferCache ?? false
|
||
const now = Date.now()
|
||
const myWxid = this.toOptionalString(this.configService.get('myWxid'))
|
||
|
||
try {
|
||
if (preferCache && this.exportStatsCache && now - this.exportStatsCache.updatedAt <= this.exportStatsCacheTtlMs) {
|
||
return {
|
||
success: true,
|
||
data: {
|
||
totalPosts: this.exportStatsCache.totalPosts,
|
||
totalFriends: this.exportStatsCache.totalFriends,
|
||
myPosts: this.exportStatsCache.myPosts
|
||
}
|
||
}
|
||
}
|
||
|
||
let { totalPosts, totalFriends, myPosts } = await this.getExportStatsFromTableCount(myWxid)
|
||
let fallbackAttempted = false
|
||
let fallbackError = ''
|
||
|
||
// 某些环境下 SnsTimeLine 统计查询会返回 0,这里在允许时回退到与导出同源的 timeline 接口统计。
|
||
if (
|
||
allowTimelineFallback &&
|
||
(totalPosts <= 0 || totalFriends <= 0) &&
|
||
now - this.lastTimelineFallbackAt >= this.timelineFallbackCooldownMs
|
||
) {
|
||
fallbackAttempted = true
|
||
try {
|
||
const timelineStats = await this.getExportStatsFromTimeline(myWxid)
|
||
this.lastTimelineFallbackAt = Date.now()
|
||
if (timelineStats.totalPosts > 0) {
|
||
totalPosts = timelineStats.totalPosts
|
||
}
|
||
if (timelineStats.totalFriends > 0) {
|
||
totalFriends = timelineStats.totalFriends
|
||
}
|
||
if (timelineStats.myPosts !== null) {
|
||
myPosts = timelineStats.myPosts
|
||
}
|
||
} catch (error) {
|
||
fallbackError = String(error)
|
||
console.error('[SnsService] getExportStats timeline fallback failed:', error)
|
||
}
|
||
}
|
||
|
||
const normalizedStats = {
|
||
totalPosts: Math.max(0, Number(totalPosts || 0)),
|
||
totalFriends: Math.max(0, Number(totalFriends || 0)),
|
||
myPosts: myWxid
|
||
? (myPosts === null ? null : Math.max(0, Number(myPosts || 0)))
|
||
: null
|
||
}
|
||
const computedHasData = normalizedStats.totalPosts > 0 || normalizedStats.totalFriends > 0
|
||
const cacheHasData = !!this.exportStatsCache && (this.exportStatsCache.totalPosts > 0 || this.exportStatsCache.totalFriends > 0)
|
||
|
||
// 计算结果全 0 时,优先使用已有非零缓存,避免瞬时异常覆盖有效统计。
|
||
if (!computedHasData && cacheHasData && this.exportStatsCache) {
|
||
return {
|
||
success: true,
|
||
data: {
|
||
totalPosts: this.exportStatsCache.totalPosts,
|
||
totalFriends: this.exportStatsCache.totalFriends,
|
||
myPosts: this.exportStatsCache.myPosts
|
||
}
|
||
}
|
||
}
|
||
|
||
// 当主查询结果全 0 且回退统计执行失败时,返回失败给前端显示明确状态(而非错误地展示 0)。
|
||
if (!computedHasData && fallbackAttempted && fallbackError) {
|
||
return { success: false, error: fallbackError }
|
||
}
|
||
|
||
this.exportStatsCache = {
|
||
totalPosts: normalizedStats.totalPosts,
|
||
totalFriends: normalizedStats.totalFriends,
|
||
myPosts: normalizedStats.myPosts,
|
||
updatedAt: Date.now()
|
||
}
|
||
|
||
return { success: true, data: normalizedStats }
|
||
} catch (e) {
|
||
if (this.exportStatsCache) {
|
||
return {
|
||
success: true,
|
||
data: {
|
||
totalPosts: this.exportStatsCache.totalPosts,
|
||
totalFriends: this.exportStatsCache.totalFriends,
|
||
myPosts: this.exportStatsCache.myPosts
|
||
}
|
||
}
|
||
}
|
||
return { success: false, error: String(e) }
|
||
}
|
||
}
|
||
|
||
async getExportStatsFast(): Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number; myPosts: number | null }; error?: string }> {
|
||
return this.getExportStats({
|
||
allowTimelineFallback: false,
|
||
preferCache: true
|
||
})
|
||
}
|
||
|
||
private async getUserPostCountsFromTimeline(): Promise<Record<string, number>> {
|
||
const pageSize = 500
|
||
const counts: Record<string, number> = {}
|
||
let offset = 0
|
||
|
||
for (let round = 0; round < 2000; round++) {
|
||
const result = await wcdbService.getSnsTimeline(pageSize, offset, undefined, undefined, 0, 0)
|
||
if (!result.success || !Array.isArray(result.timeline)) {
|
||
throw new Error(result.error || '获取朋友圈用户总条数失败')
|
||
}
|
||
|
||
const rows = result.timeline
|
||
if (rows.length === 0) break
|
||
|
||
for (const row of rows) {
|
||
const username = this.pickTimelineUsername(row)
|
||
if (!username) continue
|
||
counts[username] = (counts[username] || 0) + 1
|
||
}
|
||
|
||
if (rows.length < pageSize) break
|
||
offset += rows.length
|
||
}
|
||
|
||
return counts
|
||
}
|
||
|
||
async getUserPostCounts(options?: {
|
||
preferCache?: boolean
|
||
}): Promise<{ success: boolean; counts?: Record<string, number>; error?: string }> {
|
||
const preferCache = options?.preferCache ?? true
|
||
const now = Date.now()
|
||
|
||
try {
|
||
if (
|
||
preferCache &&
|
||
this.userPostCountsCache &&
|
||
now - this.userPostCountsCache.updatedAt <= this.userPostCountsCacheTtlMs
|
||
) {
|
||
return { success: true, counts: this.userPostCountsCache.counts }
|
||
}
|
||
|
||
const counts = await this.getUserPostCountsFromTimeline()
|
||
this.userPostCountsCache = {
|
||
counts,
|
||
updatedAt: Date.now()
|
||
}
|
||
return { success: true, counts }
|
||
} catch (error) {
|
||
console.error('[SnsService] getUserPostCounts failed:', error)
|
||
if (this.userPostCountsCache) {
|
||
return { success: true, counts: this.userPostCountsCache.counts }
|
||
}
|
||
return { success: false, error: String(error) }
|
||
}
|
||
}
|
||
|
||
async getUserPostStats(username: string): Promise<{ success: boolean; data?: { username: string; totalPosts: number }; error?: string }> {
|
||
const normalizedUsername = this.toOptionalString(username)
|
||
if (!normalizedUsername) {
|
||
return { success: false, error: '用户名不能为空' }
|
||
}
|
||
|
||
const countsResult = await this.getUserPostCounts({ preferCache: true })
|
||
if (countsResult.success) {
|
||
const totalPosts = countsResult.counts?.[normalizedUsername] ?? 0
|
||
return {
|
||
success: true,
|
||
data: {
|
||
username: normalizedUsername,
|
||
totalPosts: Math.max(0, Number(totalPosts || 0))
|
||
}
|
||
}
|
||
}
|
||
|
||
return { success: false, error: countsResult.error || '统计单个好友朋友圈失败' }
|
||
}
|
||
|
||
// 安装朋友圈删除拦截
|
||
async installSnsBlockDeleteTrigger(): Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> {
|
||
return wcdbService.installSnsBlockDeleteTrigger()
|
||
}
|
||
|
||
// 卸载朋友圈删除拦截
|
||
async uninstallSnsBlockDeleteTrigger(): Promise<{ success: boolean; error?: string }> {
|
||
return wcdbService.uninstallSnsBlockDeleteTrigger()
|
||
}
|
||
|
||
// 查询朋友圈删除拦截是否已安装
|
||
async checkSnsBlockDeleteTrigger(): Promise<{ success: boolean; installed?: boolean; error?: string }> {
|
||
return wcdbService.checkSnsBlockDeleteTrigger()
|
||
}
|
||
|
||
// 从数据库直接删除朋友圈记录
|
||
async deleteSnsPost(postId: string): Promise<{ success: boolean; error?: string }> {
|
||
const result = await wcdbService.deleteSnsPost(postId)
|
||
if (result.success) {
|
||
this.userPostCountsCache = null
|
||
this.exportStatsCache = null
|
||
}
|
||
return result
|
||
}
|
||
|
||
/**
|
||
* 补全 DLL 返回的评论中缺失的 refNickname
|
||
* DLL 返回的 refCommentId 是被回复评论的 cmtid
|
||
* 评论按 cmtid 从小到大排列,cmtid 从 1 开始递增
|
||
*/
|
||
private fixCommentRefs(comments: any[]): any[] {
|
||
if (!comments || comments.length === 0) return []
|
||
|
||
// DLL 现在返回完整的评论数据(含 emojis、refNickname)
|
||
// 此处做最终的格式化和兜底补全
|
||
const idToNickname = new Map<string, string>()
|
||
comments.forEach((c, idx) => {
|
||
if (c.id) idToNickname.set(c.id, c.nickname || '')
|
||
// 兜底:按索引映射(部分旧数据 id 可能为空)
|
||
idToNickname.set(String(idx + 1), c.nickname || '')
|
||
})
|
||
|
||
return comments.map((c) => {
|
||
const refId = c.refCommentId
|
||
let refNickname = c.refNickname || ''
|
||
|
||
if (refId && refId !== '0' && refId !== '' && !refNickname) {
|
||
refNickname = idToNickname.get(refId) || ''
|
||
}
|
||
|
||
// 处理 emojis:过滤掉空的 url 和 encryptUrl
|
||
const emojis = (c.emojis || [])
|
||
.filter((e: any) => e.url || e.encryptUrl)
|
||
.map((e: any) => ({
|
||
url: (e.url || '').replace(/&/g, '&'),
|
||
md5: e.md5 || '',
|
||
width: e.width || 0,
|
||
height: e.height || 0,
|
||
encryptUrl: e.encryptUrl ? e.encryptUrl.replace(/&/g, '&') : undefined,
|
||
aesKey: e.aesKey || undefined
|
||
}))
|
||
|
||
return {
|
||
id: c.id || '',
|
||
nickname: c.nickname || '',
|
||
content: c.content || '',
|
||
refCommentId: (refId === '0') ? '' : (refId || ''),
|
||
refNickname,
|
||
emojis: emojis.length > 0 ? emojis : undefined
|
||
}
|
||
})
|
||
}
|
||
|
||
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 || result.timeline.length === 0) return result
|
||
|
||
const enrichedTimeline = result.timeline.map((post: any) => {
|
||
const contact = this.contactCache.get(post.username)
|
||
const isVideoPost = post.type === 15
|
||
const videoKey = extractVideoKey(post.rawXml || '')
|
||
|
||
const fixedMedia = (post.media || []).map((m: any) => ({
|
||
url: fixSnsUrl(m.url, m.token, isVideoPost),
|
||
thumb: fixSnsUrl(m.thumb, m.token, false),
|
||
md5: m.md5,
|
||
token: m.token,
|
||
key: isVideoPost ? (videoKey || m.key) : m.key,
|
||
encIdx: m.encIdx || m.enc_idx,
|
||
livePhoto: m.livePhoto ? {
|
||
...m.livePhoto,
|
||
url: fixSnsUrl(m.livePhoto.url, m.livePhoto.token, true),
|
||
thumb: fixSnsUrl(m.livePhoto.thumb, m.livePhoto.token, false),
|
||
token: m.livePhoto.token,
|
||
key: videoKey || m.livePhoto.key || m.key,
|
||
encIdx: m.livePhoto.encIdx || m.livePhoto.enc_idx
|
||
} : undefined
|
||
}))
|
||
|
||
// DLL 已返回完整评论数据(含 emojis、refNickname)
|
||
// 如果 DLL 评论缺少表情包信息,回退到从 rawXml 重新解析
|
||
const dllComments: any[] = post.comments || []
|
||
const hasEmojisInDll = dllComments.some((c: any) => c.emojis && c.emojis.length > 0)
|
||
const rawXml = post.rawXml || ''
|
||
|
||
let finalComments: any[]
|
||
if (dllComments.length > 0 && (hasEmojisInDll || !rawXml)) {
|
||
// DLL 数据完整,直接使用
|
||
finalComments = this.fixCommentRefs(dllComments)
|
||
} else if (rawXml) {
|
||
// 回退:从 rawXml 重新解析(兼容旧版 DLL)
|
||
const xmlComments = parseCommentsFromXml(rawXml)
|
||
finalComments = xmlComments.length > 0 ? xmlComments : this.fixCommentRefs(dllComments)
|
||
} else {
|
||
finalComments = this.fixCommentRefs(dllComments)
|
||
}
|
||
|
||
return {
|
||
...post,
|
||
avatarUrl: contact?.avatarUrl,
|
||
nickname: post.nickname || contact?.displayName || post.username,
|
||
media: fixedMedia,
|
||
comments: finalComments
|
||
}
|
||
})
|
||
|
||
return { ...result, timeline: enrichedTimeline }
|
||
}
|
||
|
||
async debugResource(url: string): Promise<{ success: boolean; status?: number; headers?: any; error?: string }> {
|
||
return new Promise((resolve) => {
|
||
try {
|
||
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',
|
||
'Connection': 'keep-alive',
|
||
'Range': 'bytes=0-10'
|
||
}
|
||
}
|
||
|
||
const req = https.request(options, (res: any) => {
|
||
resolve({
|
||
success: true,
|
||
status: res.statusCode,
|
||
headers: {
|
||
'x-enc': res.headers['x-enc'],
|
||
'x-time': res.headers['x-time'],
|
||
'content-length': res.headers['content-length'],
|
||
'content-type': res.headers['content-type']
|
||
}
|
||
})
|
||
req.destroy()
|
||
})
|
||
|
||
req.on('error', (e: any) => resolve({ success: false, error: e.message }))
|
||
req.end()
|
||
} catch (e: any) {
|
||
resolve({ success: false, error: e.message })
|
||
}
|
||
})
|
||
}
|
||
|
||
|
||
|
||
async proxyImage(url: string, key?: string | number): Promise<{ success: boolean; dataUrl?: string; videoPath?: 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) }
|
||
}
|
||
|
||
const result = await this.fetchAndDecryptImage(url, key)
|
||
if (result.success) {
|
||
// 如果是视频,返回本地文件路径 (需配合 webSecurity: false 或自定义协议)
|
||
if (result.contentType?.startsWith('video/')) {
|
||
// Return cachePath directly for video
|
||
// 注意:fetchAndDecryptImage 需要修改以返回 cachePath
|
||
return { success: true, videoPath: result.cachePath }
|
||
}
|
||
|
||
if (result.data && result.contentType) {
|
||
const dataUrl = `data:${result.contentType};base64,${result.data.toString('base64')}`
|
||
this.imageCache.set(cacheKey, dataUrl)
|
||
return { success: true, dataUrl }
|
||
}
|
||
}
|
||
return { success: false, error: result.error }
|
||
}
|
||
|
||
async downloadImage(url: string, key?: string | number): Promise<{ success: boolean; data?: Buffer; contentType?: string; error?: string }> {
|
||
return this.fetchAndDecryptImage(url, key)
|
||
}
|
||
|
||
/**
|
||
* 导出朋友圈动态
|
||
* 支持筛选条件(用户名、关键词)和媒体文件导出
|
||
*/
|
||
async exportTimeline(options: {
|
||
outputDir: string
|
||
format: 'json' | 'html' | 'arkmejson'
|
||
usernames?: string[]
|
||
keyword?: string
|
||
exportMedia?: boolean
|
||
exportImages?: boolean
|
||
exportLivePhotos?: boolean
|
||
exportVideos?: boolean
|
||
startTime?: number
|
||
endTime?: number
|
||
}, progressCallback?: (progress: { current: number; total: number; status: string }) => void, control?: {
|
||
shouldPause?: () => boolean
|
||
shouldStop?: () => boolean
|
||
}): Promise<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; paused?: boolean; stopped?: boolean; error?: string }> {
|
||
const { outputDir, format, usernames, keyword, startTime, endTime } = options
|
||
const hasExplicitMediaSelection =
|
||
typeof options.exportImages === 'boolean' ||
|
||
typeof options.exportLivePhotos === 'boolean' ||
|
||
typeof options.exportVideos === 'boolean'
|
||
const shouldExportImages = hasExplicitMediaSelection
|
||
? options.exportImages === true
|
||
: options.exportMedia === true
|
||
const shouldExportLivePhotos = hasExplicitMediaSelection
|
||
? options.exportLivePhotos === true
|
||
: options.exportMedia === true
|
||
const shouldExportVideos = hasExplicitMediaSelection
|
||
? options.exportVideos === true
|
||
: options.exportMedia === true
|
||
const shouldExportMedia = shouldExportImages || shouldExportLivePhotos || shouldExportVideos
|
||
const getControlState = (): 'paused' | 'stopped' | null => {
|
||
if (control?.shouldStop?.()) return 'stopped'
|
||
if (control?.shouldPause?.()) return 'paused'
|
||
return null
|
||
}
|
||
const buildInterruptedResult = (state: 'paused' | 'stopped', postCount: number, mediaCount: number) => (
|
||
state === 'stopped'
|
||
? { success: true, stopped: true, filePath: '', postCount, mediaCount }
|
||
: { success: true, paused: true, filePath: '', postCount, mediaCount }
|
||
)
|
||
|
||
try {
|
||
// 确保输出目录存在
|
||
if (!existsSync(outputDir)) {
|
||
mkdirSync(outputDir, { recursive: true })
|
||
}
|
||
|
||
// 1. 分页加载全部帖子
|
||
const allPosts: SnsPost[] = []
|
||
const pageSize = 50
|
||
let endTs: number | undefined = endTime // 使用 endTime 作为分页起始上界
|
||
let hasMore = true
|
||
|
||
progressCallback?.({ current: 0, total: 0, status: '正在加载朋友圈数据...' })
|
||
|
||
while (hasMore) {
|
||
const controlState = getControlState()
|
||
if (controlState) {
|
||
return buildInterruptedResult(controlState, allPosts.length, 0)
|
||
}
|
||
const result = await this.getTimeline(pageSize, 0, usernames, keyword, startTime, endTs)
|
||
if (result.success && result.timeline && result.timeline.length > 0) {
|
||
allPosts.push(...result.timeline)
|
||
// 下一页的 endTs 为当前最后一条帖子的时间 - 1
|
||
const lastTs = result.timeline[result.timeline.length - 1].createTime - 1
|
||
endTs = lastTs
|
||
hasMore = result.timeline.length >= pageSize
|
||
// 如果已经低于 startTime,提前终止
|
||
if (startTime && lastTs < startTime) {
|
||
hasMore = false
|
||
}
|
||
progressCallback?.({ current: allPosts.length, total: 0, status: `已加载 ${allPosts.length} 条动态...` })
|
||
} else {
|
||
hasMore = false
|
||
}
|
||
}
|
||
|
||
if (allPosts.length === 0) {
|
||
return { success: true, filePath: '', postCount: 0, mediaCount: 0 }
|
||
}
|
||
|
||
progressCallback?.({ current: 0, total: allPosts.length, status: `共 ${allPosts.length} 条动态,准备导出...` })
|
||
|
||
// 2. 如果需要导出媒体,创建 media 子目录并下载
|
||
let mediaCount = 0
|
||
const mediaDir = join(outputDir, 'media')
|
||
|
||
if (shouldExportMedia) {
|
||
if (!existsSync(mediaDir)) {
|
||
mkdirSync(mediaDir, { recursive: true })
|
||
}
|
||
|
||
// 收集所有媒体下载任务
|
||
const mediaTasks: Array<{
|
||
kind: 'image' | 'video' | 'livephoto'
|
||
media: SnsMedia
|
||
url: string
|
||
key?: string
|
||
postId: string
|
||
mi: number
|
||
}> = []
|
||
for (const post of allPosts) {
|
||
post.media.forEach((media, mi) => {
|
||
const isVideo = isVideoUrl(media.url)
|
||
if (shouldExportImages && !isVideo && media.url) {
|
||
mediaTasks.push({
|
||
kind: 'image',
|
||
media,
|
||
url: media.url,
|
||
key: media.key,
|
||
postId: post.id,
|
||
mi
|
||
})
|
||
}
|
||
if (shouldExportVideos && isVideo && media.url) {
|
||
mediaTasks.push({
|
||
kind: 'video',
|
||
media,
|
||
url: media.url,
|
||
key: media.key,
|
||
postId: post.id,
|
||
mi
|
||
})
|
||
}
|
||
if (shouldExportLivePhotos && media.livePhoto?.url) {
|
||
mediaTasks.push({
|
||
kind: 'livephoto',
|
||
media,
|
||
url: media.livePhoto.url,
|
||
key: media.livePhoto.key || media.key,
|
||
postId: post.id,
|
||
mi
|
||
})
|
||
}
|
||
})
|
||
}
|
||
|
||
// 并发下载(5路)
|
||
let done = 0
|
||
const concurrency = 5
|
||
const runTask = async (task: typeof mediaTasks[0]) => {
|
||
const { media, postId, mi } = task
|
||
try {
|
||
const isVideo = task.kind === 'video' || task.kind === 'livephoto' || isVideoUrl(task.url)
|
||
const ext = isVideo ? 'mp4' : 'jpg'
|
||
const suffix = task.kind === 'livephoto' ? '_live' : ''
|
||
const fileName = `${postId}_${mi}${suffix}.${ext}`
|
||
const filePath = join(mediaDir, fileName)
|
||
|
||
if (existsSync(filePath)) {
|
||
if (task.kind === 'livephoto') {
|
||
if (media.livePhoto) (media.livePhoto as any).localPath = `media/${fileName}`
|
||
} else {
|
||
;(media as any).localPath = `media/${fileName}`
|
||
}
|
||
mediaCount++
|
||
} else {
|
||
const result = await this.fetchAndDecryptImage(task.url, task.key)
|
||
if (result.success && result.data) {
|
||
await writeFile(filePath, result.data)
|
||
if (task.kind === 'livephoto') {
|
||
if (media.livePhoto) (media.livePhoto as any).localPath = `media/${fileName}`
|
||
} else {
|
||
;(media as any).localPath = `media/${fileName}`
|
||
}
|
||
mediaCount++
|
||
} else if (result.success && result.cachePath) {
|
||
const cachedData = await readFile(result.cachePath)
|
||
await writeFile(filePath, cachedData)
|
||
if (task.kind === 'livephoto') {
|
||
if (media.livePhoto) (media.livePhoto as any).localPath = `media/${fileName}`
|
||
} else {
|
||
;(media as any).localPath = `media/${fileName}`
|
||
}
|
||
mediaCount++
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.warn(`[SnsExport] 媒体下载失败: ${task.url}`, e)
|
||
}
|
||
done++
|
||
progressCallback?.({ current: done, total: mediaTasks.length, status: `正在下载媒体 (${done}/${mediaTasks.length})...` })
|
||
}
|
||
|
||
// 控制并发的执行器
|
||
const queue = [...mediaTasks]
|
||
const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => {
|
||
while (queue.length > 0) {
|
||
const controlState = getControlState()
|
||
if (controlState) return controlState
|
||
const task = queue.shift()!
|
||
await runTask(task)
|
||
}
|
||
return null
|
||
})
|
||
const workerResults = await Promise.all(workers)
|
||
const interruptedState = workerResults.find(state => state === 'paused' || state === 'stopped')
|
||
if (interruptedState) {
|
||
return buildInterruptedResult(interruptedState, allPosts.length, mediaCount)
|
||
}
|
||
}
|
||
|
||
// 2.5 下载头像
|
||
const avatarMap = new Map<string, string>()
|
||
if (format === 'html') {
|
||
if (!existsSync(mediaDir)) mkdirSync(mediaDir, { recursive: true })
|
||
const uniqueUsers = [...new Map(allPosts.filter(p => p.avatarUrl).map(p => [p.username, p])).values()]
|
||
let avatarDone = 0
|
||
const avatarQueue = [...uniqueUsers]
|
||
const avatarWorkers = Array.from({ length: Math.min(5, avatarQueue.length) }, async () => {
|
||
while (avatarQueue.length > 0) {
|
||
const controlState = getControlState()
|
||
if (controlState) return controlState
|
||
const post = avatarQueue.shift()!
|
||
try {
|
||
const fileName = `avatar_${crypto.createHash('md5').update(post.username).digest('hex').slice(0, 8)}.jpg`
|
||
const filePath = join(mediaDir, fileName)
|
||
if (existsSync(filePath)) {
|
||
avatarMap.set(post.username, `media/${fileName}`)
|
||
} else {
|
||
const result = await this.fetchAndDecryptImage(post.avatarUrl!)
|
||
if (result.success && result.data) {
|
||
await writeFile(filePath, result.data)
|
||
avatarMap.set(post.username, `media/${fileName}`)
|
||
}
|
||
}
|
||
} catch (e) { /* 头像下载失败不影响导出 */ }
|
||
avatarDone++
|
||
progressCallback?.({ current: avatarDone, total: uniqueUsers.length, status: `正在下载头像 (${avatarDone}/${uniqueUsers.length})...` })
|
||
}
|
||
return null
|
||
})
|
||
const avatarWorkerResults = await Promise.all(avatarWorkers)
|
||
const interruptedState = avatarWorkerResults.find(state => state === 'paused' || state === 'stopped')
|
||
if (interruptedState) {
|
||
return buildInterruptedResult(interruptedState, allPosts.length, mediaCount)
|
||
}
|
||
}
|
||
|
||
// 3. 生成输出文件
|
||
const finalControlState = getControlState()
|
||
if (finalControlState) {
|
||
return buildInterruptedResult(finalControlState, allPosts.length, mediaCount)
|
||
}
|
||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
|
||
let outputFilePath: string
|
||
|
||
if (format === 'json') {
|
||
outputFilePath = join(outputDir, `朋友圈导出_${timestamp}.json`)
|
||
const exportData = {
|
||
exportTime: new Date().toISOString(),
|
||
totalPosts: allPosts.length,
|
||
filters: {
|
||
usernames: usernames || [],
|
||
keyword: keyword || ''
|
||
},
|
||
posts: allPosts.map(p => ({
|
||
id: p.id,
|
||
username: p.username,
|
||
nickname: p.nickname,
|
||
createTime: p.createTime,
|
||
createTimeStr: new Date(p.createTime * 1000).toLocaleString('zh-CN'),
|
||
contentDesc: p.contentDesc,
|
||
type: p.type,
|
||
media: p.media.map(m => ({
|
||
url: m.url,
|
||
thumb: m.thumb,
|
||
localPath: (m as any).localPath || undefined
|
||
})),
|
||
likes: p.likes,
|
||
comments: p.comments,
|
||
linkTitle: (p as any).linkTitle,
|
||
linkUrl: (p as any).linkUrl
|
||
}))
|
||
}
|
||
await writeFile(outputFilePath, JSON.stringify(exportData, null, 2), 'utf-8')
|
||
} else if (format === 'arkmejson') {
|
||
outputFilePath = join(outputDir, `朋友圈导出_${timestamp}.json`)
|
||
progressCallback?.({ current: 0, total: allPosts.length, status: '正在构建 ArkmeJSON 数据...' })
|
||
|
||
const identityCache = new Map<string, Promise<SnsContactIdentity | null>>()
|
||
const posts: any[] = []
|
||
let built = 0
|
||
|
||
for (const post of allPosts) {
|
||
const controlState = getControlState()
|
||
if (controlState) {
|
||
return buildInterruptedResult(controlState, allPosts.length, mediaCount)
|
||
}
|
||
|
||
const authorIdentity = await this.resolveContactIdentity(post.username, identityCache)
|
||
const { likesDetail, commentsDetail } = await this.buildArkmeInteractionDetails(post, identityCache)
|
||
|
||
posts.push({
|
||
id: post.id,
|
||
username: post.username,
|
||
nickname: post.nickname,
|
||
author: authorIdentity
|
||
? {
|
||
...authorIdentity
|
||
}
|
||
: {
|
||
username: post.username,
|
||
wxid: post.username,
|
||
displayName: post.nickname || post.username
|
||
},
|
||
createTime: post.createTime,
|
||
createTimeStr: new Date(post.createTime * 1000).toLocaleString('zh-CN'),
|
||
contentDesc: post.contentDesc,
|
||
type: post.type,
|
||
media: post.media.map(m => ({
|
||
url: m.url,
|
||
thumb: m.thumb,
|
||
localPath: (m as any).localPath || undefined,
|
||
livePhoto: m.livePhoto ? {
|
||
url: m.livePhoto.url,
|
||
thumb: m.livePhoto.thumb,
|
||
localPath: (m.livePhoto as any).localPath || undefined
|
||
} : undefined
|
||
})),
|
||
likes: post.likes,
|
||
comments: post.comments,
|
||
likesDetail,
|
||
commentsDetail,
|
||
linkTitle: (post as any).linkTitle,
|
||
linkUrl: (post as any).linkUrl
|
||
})
|
||
|
||
built++
|
||
if (built % 20 === 0 || built === allPosts.length) {
|
||
progressCallback?.({ current: built, total: allPosts.length, status: `正在构建 ArkmeJSON 数据 (${built}/${allPosts.length})...` })
|
||
}
|
||
}
|
||
|
||
const ownerWxid = this.toOptionalString(this.configService.get('myWxid'))
|
||
const ownerIdentity = ownerWxid
|
||
? await this.resolveContactIdentity(ownerWxid, identityCache)
|
||
: null
|
||
const recordOwner = ownerIdentity
|
||
? { ...ownerIdentity }
|
||
: ownerWxid
|
||
? { username: ownerWxid, wxid: ownerWxid, displayName: ownerWxid }
|
||
: { username: '', wxid: '', displayName: '' }
|
||
|
||
const exportData = {
|
||
exportTime: new Date().toISOString(),
|
||
format: 'arkmejson',
|
||
schemaVersion: '1.0.0',
|
||
recordOwner,
|
||
mediaSelection: {
|
||
images: shouldExportImages,
|
||
livePhotos: shouldExportLivePhotos,
|
||
videos: shouldExportVideos
|
||
},
|
||
totalPosts: allPosts.length,
|
||
filters: {
|
||
usernames: usernames || [],
|
||
keyword: keyword || ''
|
||
},
|
||
posts
|
||
}
|
||
await writeFile(outputFilePath, JSON.stringify(exportData, null, 2), 'utf-8')
|
||
} else {
|
||
// HTML 格式
|
||
outputFilePath = join(outputDir, `朋友圈导出_${timestamp}.html`)
|
||
const html = this.generateHtml(allPosts, { usernames, keyword }, avatarMap)
|
||
await writeFile(outputFilePath, html, 'utf-8')
|
||
}
|
||
|
||
progressCallback?.({ current: allPosts.length, total: allPosts.length, status: '导出完成!' })
|
||
|
||
return { success: true, filePath: outputFilePath, postCount: allPosts.length, mediaCount }
|
||
} catch (e: any) {
|
||
console.error('[SnsExport] 导出失败:', e)
|
||
return { success: false, error: e.message || String(e) }
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 生成朋友圈 HTML 导出文件
|
||
*/
|
||
private generateHtml(posts: SnsPost[], filters: { usernames?: string[]; keyword?: string }, avatarMap?: Map<string, string>): string {
|
||
const escapeHtml = (str: string) => str
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/\n/g, '<br>')
|
||
|
||
const formatTime = (ts: number) => {
|
||
const d = new Date(ts * 1000)
|
||
const now = new Date()
|
||
const isCurrentYear = d.getFullYear() === now.getFullYear()
|
||
const pad = (n: number) => String(n).padStart(2, '0')
|
||
const timeStr = `${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||
const m = d.getMonth() + 1, day = d.getDate()
|
||
return isCurrentYear ? `${m}月${day}日 ${timeStr}` : `${d.getFullYear()}年${m}月${day}日 ${timeStr}`
|
||
}
|
||
|
||
// 生成头像首字母
|
||
const avatarLetter = (name: string) => {
|
||
const ch = name.charAt(0)
|
||
return escapeHtml(ch || '?')
|
||
}
|
||
|
||
let filterInfo = ''
|
||
if (filters.keyword) filterInfo += `关键词: "${escapeHtml(filters.keyword)}" `
|
||
if (filters.usernames && filters.usernames.length > 0) filterInfo += `筛选用户: ${filters.usernames.length} 人`
|
||
|
||
const postsHtml = posts.map(post => {
|
||
const mediaCount = post.media.length
|
||
const gridClass = mediaCount === 1 ? 'grid-1' : mediaCount === 2 || mediaCount === 4 ? 'grid-2' : 'grid-3'
|
||
|
||
const mediaHtml = post.media.map((m, mi) => {
|
||
const localPath = (m as any).localPath
|
||
if (localPath) {
|
||
if (isVideoUrl(m.url)) {
|
||
return `<div class="mi"><video src="${escapeHtml(localPath)}" controls preload="metadata"></video></div>`
|
||
}
|
||
return `<div class="mi"><img src="${escapeHtml(localPath)}" loading="lazy" onclick="openLb(this.src)" alt=""></div>`
|
||
}
|
||
return `<div class="mi ml"><a href="${escapeHtml(m.url)}" target="_blank">查看媒体</a></div>`
|
||
}).join('')
|
||
|
||
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 likesHtml = post.likes.length > 0
|
||
? `<div class="interactions"><div class="likes">♥ ${post.likes.map(l => `<span>${escapeHtml(l)}</span>`).join('、')}</div></div>`
|
||
: ''
|
||
|
||
const commentsHtml = post.comments.length > 0
|
||
? `<div class="interactions${post.likes.length > 0 ? ' cmt-border' : ''}"><div class="cmts">${post.comments.map(c => {
|
||
const ref = c.refNickname ? `<span class="re">回复</span><b>${escapeHtml(c.refNickname)}</b>` : ''
|
||
return `<div class="cmt"><b>${escapeHtml(c.nickname)}</b>${ref}:${escapeHtml(c.content)}</div>`
|
||
}).join('')}</div></div>`
|
||
: ''
|
||
|
||
const avatarSrc = avatarMap?.get(post.username)
|
||
const avatarHtml = avatarSrc
|
||
? `<div class="avatar"><img src="${escapeHtml(avatarSrc)}" alt=""></div>`
|
||
: `<div class="avatar">${avatarLetter(post.nickname)}</div>`
|
||
|
||
return `<div class="post">
|
||
${avatarHtml}
|
||
<div class="body">
|
||
<div class="hd"><span class="nick">${escapeHtml(post.nickname)}</span><span class="tm">${formatTime(post.createTime)}</span></div>
|
||
${post.contentDesc ? `<div class="txt">${escapeHtml(post.contentDesc)}</div>` : ''}
|
||
${mediaHtml ? `<div class="mg ${gridClass}">${mediaHtml}</div>` : ''}
|
||
${linkHtml}
|
||
${likesHtml}
|
||
${commentsHtml}
|
||
</div></div>`
|
||
}).join('\n')
|
||
|
||
return `<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>朋友圈导出</title>
|
||
<style>
|
||
*{margin:0;padding:0;box-sizing:border-box}
|
||
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif;background:var(--bg);color:var(--t1);line-height:1.6;-webkit-font-smoothing:antialiased}
|
||
:root{--bg:#F0EEE9;--card:rgba(255,255,255,.92);--t1:#3d3d3d;--t2:#666;--t3:#999;--accent:#8B7355;--border:rgba(0,0,0,.08);--bg3:rgba(0,0,0,.03)}
|
||
@media(prefers-color-scheme:dark){:root{--bg:#1a1a1a;--card:rgba(40,40,40,.85);--t1:#e0e0e0;--t2:#aaa;--t3:#777;--accent:#c4a882;--border:rgba(255,255,255,.1);--bg3:rgba(255,255,255,.06)}}
|
||
.container{max-width:800px;margin:0 auto;padding:20px 24px 60px}
|
||
|
||
/* 页面标题 */
|
||
.feed-hd{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;padding:0 4px}
|
||
.feed-hd h2{font-size:20px;font-weight:700}
|
||
.feed-hd .info{font-size:12px;color:var(--t3)}
|
||
|
||
/* 帖子卡片 - 头像+内容双列 */
|
||
.post{background:var(--card);border-radius:16px;border:1px solid var(--border);padding:20px;margin-bottom:24px;display:flex;gap:16px;box-shadow:0 2px 8px rgba(0,0,0,.02);transition:transform .2s,box-shadow .2s}
|
||
.post:hover{transform:translateY(-2px);box-shadow:0 8px 16px rgba(0,0,0,.06)}
|
||
.avatar{width:48px;height:48px;border-radius:12px;background:var(--accent);color:#fff;display:flex;align-items:center;justify-content:center;font-size:20px;font-weight:600;flex-shrink:0;overflow:hidden}
|
||
.avatar img{width:100%;height:100%;object-fit:cover}
|
||
.body{flex:1;min-width:0}
|
||
.hd{display:flex;flex-direction:column;margin-bottom:8px}
|
||
.nick{font-size:15px;font-weight:700;color:var(--accent);margin-bottom:2px}
|
||
.tm{font-size:12px;color:var(--t3)}
|
||
.txt{font-size:15px;line-height:1.6;white-space:pre-wrap;word-break:break-word;margin-bottom:12px}
|
||
|
||
/* 媒体网格 */
|
||
.mg{display:grid;gap:6px;margin-bottom:12px;max-width:320px}
|
||
.grid-1{max-width:300px}
|
||
.grid-1 .mi{border-radius:12px}
|
||
.grid-1 .mi img{aspect-ratio:auto;max-height:480px;object-fit:contain;background:var(--bg3)}
|
||
.grid-2{grid-template-columns:1fr 1fr}
|
||
.grid-3{grid-template-columns:1fr 1fr 1fr}
|
||
.mi{overflow:hidden;border-radius:12px;background:var(--bg3);position:relative;aspect-ratio:1}
|
||
.mi img{width:100%;height:100%;object-fit:cover;display:block;cursor:zoom-in;transition:opacity .2s}
|
||
.mi img:hover{opacity:.9}
|
||
.mi video{width:100%;height:100%;object-fit:cover;display:block;background:#000}
|
||
.ml{display:flex;align-items:center;justify-content:center}
|
||
.ml a{color:var(--accent);text-decoration:none;font-size:13px}
|
||
|
||
/* 链接卡片 */
|
||
.lk{display:flex;align-items:center;gap:10px;padding:10px;background:var(--bg3);border:1px solid var(--border);border-radius:12px;text-decoration:none;color:var(--t1);font-size:14px;margin-bottom:12px;transition:background .15s}
|
||
.lk:hover{background:var(--border)}
|
||
.lk-t{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:600}
|
||
.lk-a{color:var(--t3);font-size:18px;flex-shrink:0}
|
||
|
||
/* 互动区域 */
|
||
.interactions{margin-top:12px;padding-top:12px;border-top:1px dashed var(--border);font-size:13px}
|
||
.interactions.cmt-border{border-top:none;padding-top:0;margin-top:8px}
|
||
.likes{color:var(--accent);font-weight:500;line-height:1.8}
|
||
.cmts{background:var(--bg3);border-radius:8px;padding:8px 12px;line-height:1.4}
|
||
.cmt{margin-bottom:4px;color:var(--t2)}
|
||
.cmt:last-child{margin-bottom:0}
|
||
.cmt b{color:var(--accent);font-weight:500}
|
||
.re{color:var(--t3);margin:0 4px;font-size:12px}
|
||
|
||
/* 灯箱 */
|
||
.lb{display:none;position:fixed;inset:0;background:rgba(0,0,0,.92);z-index:9999;align-items:center;justify-content:center;cursor:zoom-out}
|
||
.lb.on{display:flex}
|
||
.lb img{max-width:92vw;max-height:92vh;object-fit:contain;border-radius:4px}
|
||
|
||
/* 回到顶部 */
|
||
.btt{position:fixed;right:24px;bottom:32px;width:44px;height:44px;border-radius:50%;background:var(--card);box-shadow:0 2px 12px rgba(0,0,0,.12);border:1px solid var(--border);cursor:pointer;font-size:18px;display:none;align-items:center;justify-content:center;z-index:100;color:var(--t2)}
|
||
.btt:hover{transform:scale(1.1)}
|
||
.btt.show{display:flex}
|
||
|
||
/* 页脚 */
|
||
.ft{text-align:center;padding:32px 0 24px;font-size:12px;color:var(--t3)}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
<div class="feed-hd"><h2>朋友圈</h2><span class="info">共 ${posts.length} 条${filterInfo ? ` · ${filterInfo}` : ''}</span></div>
|
||
${postsHtml}
|
||
<div class="ft">由 WeFlow 导出 · ${new Date().toLocaleString('zh-CN')}</div>
|
||
</div>
|
||
<div class="lb" id="lb" onclick="closeLb()"><img id="lbi" src=""></div>
|
||
<button class="btt" id="btt" onclick="scrollTo({top:0,behavior:'smooth'})">↑</button>
|
||
<script>
|
||
function openLb(s){document.getElementById('lbi').src=s;document.getElementById('lb').classList.add('on');document.body.style.overflow='hidden'}
|
||
function closeLb(){document.getElementById('lb').classList.remove('on');document.body.style.overflow=''}
|
||
document.addEventListener('keydown',function(e){if(e.key==='Escape')closeLb()})
|
||
window.addEventListener('scroll',function(){document.getElementById('btt').classList.toggle('show',window.scrollY>600)})
|
||
</script>
|
||
</body>
|
||
</html>`
|
||
}
|
||
|
||
private async fetchAndDecryptImage(url: string, key?: string | number): Promise<{ success: boolean; data?: Buffer; contentType?: string; cachePath?: string; error?: string }> {
|
||
if (!url) return { success: false, error: 'url 不能为空' }
|
||
|
||
const isVideo = isVideoUrl(url)
|
||
const cachePath = this.getCacheFilePath(url)
|
||
|
||
// 1. 尝试从磁盘缓存读取
|
||
if (existsSync(cachePath)) {
|
||
try {
|
||
// 对于视频,不读取整个文件到内存,只确认存在即可
|
||
if (isVideo) {
|
||
return { success: true, cachePath, contentType: 'video/mp4' }
|
||
}
|
||
|
||
const data = await readFile(cachePath)
|
||
const contentType = detectImageMime(data)
|
||
return { success: true, data, contentType, cachePath }
|
||
} catch (e) {
|
||
console.warn(`[SnsService] 读取缓存失败: ${cachePath}`, e)
|
||
}
|
||
}
|
||
|
||
if (isVideo) {
|
||
// 视频专用下载逻辑 (下载 -> 解密 -> 缓存)
|
||
return new Promise(async (resolve) => {
|
||
const tmpPath = join(require('os').tmpdir(), `sns_video_${Date.now()}_${Math.random().toString(36).slice(2)}.enc`)
|
||
|
||
try {
|
||
const https = require('https')
|
||
const urlObj = new URL(url)
|
||
const fs = require('fs')
|
||
|
||
const fileStream = fs.createWriteStream(tmpPath)
|
||
|
||
const options = {
|
||
hostname: urlObj.hostname,
|
||
path: urlObj.pathname + urlObj.search,
|
||
method: 'GET',
|
||
headers: {
|
||
'User-Agent': 'MicroMessenger Client',
|
||
'Accept': '*/*',
|
||
// 'Accept-Encoding': 'gzip, deflate, br', // 视频流通常不压缩,去掉以免 stream 处理复杂
|
||
'Connection': 'keep-alive'
|
||
}
|
||
}
|
||
|
||
const req = https.request(options, (res: any) => {
|
||
if (res.statusCode !== 200 && res.statusCode !== 206) {
|
||
fileStream.close()
|
||
fs.unlink(tmpPath, () => { }) // 删除临时文件
|
||
resolve({ success: false, error: `HTTP ${res.statusCode}` })
|
||
return
|
||
}
|
||
|
||
res.pipe(fileStream)
|
||
fileStream.on('finish', async () => {
|
||
fileStream.close()
|
||
|
||
try {
|
||
const encryptedBuffer = await readFile(tmpPath)
|
||
const raw = encryptedBuffer // 引用,方便后续操作
|
||
|
||
|
||
if (key && String(key).trim().length > 0) {
|
||
try {
|
||
const keyText = String(key).trim()
|
||
let keystream: Buffer
|
||
|
||
try {
|
||
const wasmService = WasmService.getInstance()
|
||
// 只需要前 128KB (131072 bytes) 用于解密头部
|
||
keystream = await wasmService.getKeystream(keyText, 131072)
|
||
} catch (wasmErr) {
|
||
// 打包漏带 wasm 或 wasm 初始化异常时,回退到纯 TS ISAAC64
|
||
const isaac = new Isaac64(keyText)
|
||
keystream = isaac.generateKeystreamBE(131072)
|
||
}
|
||
|
||
const decryptLen = Math.min(keystream.length, raw.length)
|
||
|
||
// XOR 解密
|
||
for (let i = 0; i < decryptLen; i++) {
|
||
raw[i] ^= keystream[i]
|
||
}
|
||
|
||
// 验证 MP4 签名 ('ftyp' at offset 4)
|
||
const ftyp = raw.subarray(4, 8).toString('ascii')
|
||
if (ftyp !== 'ftyp') {
|
||
// 可以在此处记录解密可能失败的标记,但不打印详细 hex
|
||
}
|
||
} catch (err) {
|
||
console.error(`[SnsService] 视频解密出错: ${err}`)
|
||
}
|
||
}
|
||
|
||
// 写入最终缓存 (覆盖)
|
||
await writeFile(cachePath, raw)
|
||
|
||
// 删除临时文件
|
||
try { await import('fs/promises').then(fs => fs.unlink(tmpPath)) } catch (e) { }
|
||
|
||
resolve({ success: true, data: raw, contentType: 'video/mp4', cachePath })
|
||
} catch (e: any) {
|
||
console.error(`[SnsService] 视频处理失败:`, e)
|
||
resolve({ success: false, error: e.message })
|
||
}
|
||
})
|
||
})
|
||
|
||
req.on('error', (e: any) => {
|
||
fs.unlink(tmpPath, () => { })
|
||
resolve({ success: false, error: e.message })
|
||
})
|
||
|
||
req.setTimeout(15000, () => {
|
||
req.destroy()
|
||
fs.unlink(tmpPath, () => { })
|
||
resolve({ success: false, error: '请求超时' })
|
||
})
|
||
|
||
req.end()
|
||
|
||
} catch (e: any) {
|
||
resolve({ success: false, error: e.message })
|
||
}
|
||
})
|
||
}
|
||
|
||
// 图片逻辑 (保持流式处理)
|
||
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': 'MicroMessenger Client',
|
||
'Accept': '*/*',
|
||
'Accept-Encoding': 'gzip, deflate, br',
|
||
'Accept-Language': 'zh-CN,zh;q=0.9',
|
||
'Connection': 'keep-alive'
|
||
}
|
||
}
|
||
|
||
const req = https.request(options, (res: any) => {
|
||
if (res.statusCode !== 200 && res.statusCode !== 206) {
|
||
resolve({ success: false, error: `HTTP ${res.statusCode}` })
|
||
return
|
||
}
|
||
|
||
const chunks: Buffer[] = []
|
||
let stream = res
|
||
|
||
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', async () => {
|
||
const raw = Buffer.concat(chunks)
|
||
const xEnc = String(res.headers['x-enc'] || '').trim()
|
||
|
||
let decoded = raw
|
||
|
||
// 图片逻辑
|
||
const shouldDecrypt = (xEnc === '1' || !!key) && key !== undefined && key !== null && String(key).trim().length > 0
|
||
if (shouldDecrypt) {
|
||
try {
|
||
const keyStr = String(key).trim()
|
||
if (/^\d+$/.test(keyStr)) {
|
||
// 使用 WASM 版本的 Isaac64 解密图片
|
||
// 修正逻辑:使用带 reverse 且修正了 8字节对齐偏移的 getKeystream
|
||
const wasmService = WasmService.getInstance()
|
||
const keystream = await wasmService.getKeystream(keyStr, raw.length)
|
||
|
||
const decrypted = Buffer.allocUnsafe(raw.length)
|
||
for (let i = 0; i < raw.length; i++) {
|
||
decrypted[i] = raw[i] ^ keystream[i]
|
||
}
|
||
|
||
decoded = decrypted
|
||
}
|
||
} catch (e) {
|
||
console.error('[SnsService] TS Decrypt Error:', e)
|
||
}
|
||
}
|
||
|
||
// 写入磁盘缓存
|
||
try {
|
||
await writeFile(cachePath, decoded)
|
||
} catch (e) {
|
||
console.warn(`[SnsService] 写入缓存失败: ${cachePath}`, e)
|
||
}
|
||
|
||
const contentType = detectImageMime(decoded, (res.headers['content-type'] || 'image/jpeg') as string)
|
||
resolve({ success: true, data: decoded, contentType, cachePath })
|
||
})
|
||
stream.on('error', (e: any) => resolve({ success: false, error: e.message }))
|
||
})
|
||
|
||
req.on('error', (e: any) => resolve({ success: false, error: e.message }))
|
||
req.setTimeout(15000, () => {
|
||
req.destroy()
|
||
resolve({ success: false, error: '请求超时' })
|
||
})
|
||
req.end()
|
||
} catch (e: any) {
|
||
resolve({ success: false, error: e.message })
|
||
}
|
||
})
|
||
}
|
||
|
||
/** 判断 buffer 是否为有效图片头 */
|
||
private isValidImageBuffer(buf: Buffer): boolean {
|
||
if (!buf || buf.length < 12) return false
|
||
if (buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46) return true
|
||
if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4E && buf[3] === 0x47) return true
|
||
if (buf[0] === 0xFF && buf[1] === 0xD8 && buf[2] === 0xFF) return true
|
||
if (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 true
|
||
return false
|
||
}
|
||
|
||
/** 根据图片头返回扩展名 */
|
||
private getImageExtFromBuffer(buf: Buffer): string {
|
||
if (buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46) return '.gif'
|
||
if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4E && buf[3] === 0x47) return '.png'
|
||
if (buf[0] === 0xFF && buf[1] === 0xD8 && buf[2] === 0xFF) return '.jpg'
|
||
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 '.webp'
|
||
return '.gif'
|
||
}
|
||
|
||
/** 构建多种密钥派生方式 */
|
||
private buildKeyTries(aesKey: string): { name: string; key: Buffer }[] {
|
||
const keyTries: { name: string; key: Buffer }[] = []
|
||
const hexStr = aesKey.replace(/\s/g, '')
|
||
if (hexStr.length >= 32 && /^[0-9a-fA-F]+$/.test(hexStr)) {
|
||
try {
|
||
const keyBuf = Buffer.from(hexStr.slice(0, 32), 'hex')
|
||
if (keyBuf.length === 16) keyTries.push({ name: 'hex-decode', key: keyBuf })
|
||
} catch { }
|
||
const rawKey = Buffer.from(hexStr.slice(0, 32), 'utf8')
|
||
if (rawKey.length === 32) keyTries.push({ name: 'raw-hex-str-32', key: rawKey })
|
||
}
|
||
if (aesKey.length >= 16) {
|
||
keyTries.push({ name: 'utf8-16', key: Buffer.from(aesKey, 'utf8').subarray(0, 16) })
|
||
}
|
||
keyTries.push({ name: 'md5', key: crypto.createHash('md5').update(aesKey).digest() })
|
||
try {
|
||
const b64Buf = Buffer.from(aesKey, 'base64')
|
||
if (b64Buf.length >= 16) keyTries.push({ name: 'base64', key: b64Buf.subarray(0, 16) })
|
||
} catch { }
|
||
return keyTries
|
||
}
|
||
|
||
/** 构建多种 GCM 数据布局 */
|
||
private buildGcmLayouts(encData: Buffer): { nonce: Buffer; ciphertext: Buffer; tag: Buffer }[] {
|
||
const layouts: { nonce: Buffer; ciphertext: Buffer; tag: Buffer }[] = []
|
||
// 格式 A:GcmData 块格式
|
||
if (encData.length > 63 && encData[0] === 0xAB && encData[8] === 0xAB && encData[9] === 0x00) {
|
||
const payloadSize = encData.readUInt32LE(10)
|
||
if (payloadSize > 16 && 63 + payloadSize <= encData.length) {
|
||
const nonce = encData.subarray(19, 31)
|
||
const payload = encData.subarray(63, 63 + payloadSize)
|
||
layouts.push({ nonce, ciphertext: payload.subarray(0, payload.length - 16), tag: payload.subarray(payload.length - 16) })
|
||
}
|
||
}
|
||
// 格式 B:尾部 [ciphertext][nonce 12B][tag 16B]
|
||
if (encData.length > 28) {
|
||
layouts.push({
|
||
ciphertext: encData.subarray(0, encData.length - 28),
|
||
nonce: encData.subarray(encData.length - 28, encData.length - 16),
|
||
tag: encData.subarray(encData.length - 16)
|
||
})
|
||
}
|
||
// 格式 C:前置 [nonce 12B][ciphertext][tag 16B]
|
||
if (encData.length > 28) {
|
||
layouts.push({
|
||
nonce: encData.subarray(0, 12),
|
||
ciphertext: encData.subarray(12, encData.length - 16),
|
||
tag: encData.subarray(encData.length - 16)
|
||
})
|
||
}
|
||
// 格式 D:零 nonce
|
||
if (encData.length > 16) {
|
||
layouts.push({
|
||
nonce: Buffer.alloc(12, 0),
|
||
ciphertext: encData.subarray(0, encData.length - 16),
|
||
tag: encData.subarray(encData.length - 16)
|
||
})
|
||
}
|
||
// 格式 E:[nonce 12B][tag 16B][ciphertext]
|
||
if (encData.length > 28) {
|
||
layouts.push({
|
||
nonce: encData.subarray(0, 12),
|
||
tag: encData.subarray(12, 28),
|
||
ciphertext: encData.subarray(28)
|
||
})
|
||
}
|
||
return layouts
|
||
}
|
||
|
||
/** 尝试 AES-GCM 解密 */
|
||
private tryGcmDecrypt(key: Buffer, nonce: Buffer, ciphertext: Buffer, tag: Buffer): Buffer | null {
|
||
try {
|
||
const algo = key.length === 32 ? 'aes-256-gcm' : 'aes-128-gcm'
|
||
const decipher = crypto.createDecipheriv(algo, key, nonce)
|
||
decipher.setAuthTag(tag)
|
||
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()])
|
||
if (this.isValidImageBuffer(decrypted)) return decrypted
|
||
for (const fn of [zlib.inflateSync, zlib.gunzipSync, zlib.unzipSync]) {
|
||
try {
|
||
const d = fn(decrypted)
|
||
if (this.isValidImageBuffer(d)) return d
|
||
} catch { }
|
||
}
|
||
return decrypted
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 解密表情数据(多种算法 + 多种密钥派生)
|
||
* 移植自 ciphertalk 的逆向实现
|
||
*/
|
||
private decryptEmojiAes(encData: Buffer, aesKey: string): Buffer | null {
|
||
if (encData.length <= 16) return null
|
||
|
||
const keyTries = this.buildKeyTries(aesKey)
|
||
const tag = encData.subarray(encData.length - 16)
|
||
const ciphertext = encData.subarray(0, encData.length - 16)
|
||
|
||
// 最高优先级:nonce-tail 格式 [ciphertext][nonce 12B][tag 16B]
|
||
if (encData.length > 28) {
|
||
const nonceTail = encData.subarray(encData.length - 28, encData.length - 16)
|
||
const tagTail = encData.subarray(encData.length - 16)
|
||
const cipherTail = encData.subarray(0, encData.length - 28)
|
||
for (const { key } of keyTries) {
|
||
if (key.length !== 16 && key.length !== 32) continue
|
||
const result = this.tryGcmDecrypt(key, nonceTail, cipherTail, tagTail)
|
||
if (result) return result
|
||
}
|
||
}
|
||
|
||
// 次优先级:nonce = key 前 12 字节
|
||
for (const { key } of keyTries) {
|
||
if (key.length !== 16 && key.length !== 32) continue
|
||
const nonce = key.subarray(0, 12)
|
||
const result = this.tryGcmDecrypt(key, nonce, ciphertext, tag)
|
||
if (result) return result
|
||
}
|
||
|
||
// 其他 GCM 布局
|
||
const layouts = this.buildGcmLayouts(encData)
|
||
for (const layout of layouts) {
|
||
for (const { key } of keyTries) {
|
||
if (key.length !== 16 && key.length !== 32) continue
|
||
const result = this.tryGcmDecrypt(key, layout.nonce, layout.ciphertext, layout.tag)
|
||
if (result) return result
|
||
}
|
||
}
|
||
|
||
// 回退:AES-128-CBC / AES-128-ECB
|
||
for (const { key } of keyTries) {
|
||
if (key.length !== 16) continue
|
||
// CBC:IV = key
|
||
if (encData.length >= 16 && encData.length % 16 === 0) {
|
||
try {
|
||
const dec = crypto.createDecipheriv('aes-128-cbc', key, key)
|
||
dec.setAutoPadding(true)
|
||
const result = Buffer.concat([dec.update(encData), dec.final()])
|
||
if (this.isValidImageBuffer(result)) return result
|
||
for (const fn of [zlib.inflateSync, zlib.gunzipSync]) {
|
||
try { const d = fn(result); if (this.isValidImageBuffer(d)) return d } catch { }
|
||
}
|
||
} catch { }
|
||
}
|
||
// CBC:前 16 字节作为 IV
|
||
if (encData.length > 32) {
|
||
try {
|
||
const iv = encData.subarray(0, 16)
|
||
const dec = crypto.createDecipheriv('aes-128-cbc', key, iv)
|
||
dec.setAutoPadding(true)
|
||
const result = Buffer.concat([dec.update(encData.subarray(16)), dec.final()])
|
||
if (this.isValidImageBuffer(result)) return result
|
||
} catch { }
|
||
}
|
||
// ECB
|
||
try {
|
||
const dec = crypto.createDecipheriv('aes-128-ecb', key, null)
|
||
dec.setAutoPadding(true)
|
||
const result = Buffer.concat([dec.update(encData), dec.final()])
|
||
if (this.isValidImageBuffer(result)) return result
|
||
} catch { }
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
/** 下载原始数据到本地临时文件,支持重定向 */
|
||
private doDownloadRaw(targetUrl: string, cacheKey: string, cacheDir: string): Promise<string | null> {
|
||
return new Promise((resolve) => {
|
||
try {
|
||
const fs = require('fs')
|
||
const https = require('https')
|
||
const http = require('http')
|
||
let fixedUrl = targetUrl.replace(/&/g, '&')
|
||
const urlObj = new URL(fixedUrl)
|
||
const protocol = fixedUrl.startsWith('https') ? https : http
|
||
|
||
const options = {
|
||
headers: {
|
||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 MicroMessenger/7.0.20.1781(0x67001431)',
|
||
'Accept': '*/*',
|
||
'Connection': 'keep-alive'
|
||
},
|
||
rejectUnauthorized: false,
|
||
timeout: 15000
|
||
}
|
||
|
||
const request = protocol.get(fixedUrl, options, (response: any) => {
|
||
// 处理重定向
|
||
if ([301, 302, 303, 307].includes(response.statusCode)) {
|
||
const redirectUrl = response.headers.location
|
||
if (redirectUrl) {
|
||
const full = redirectUrl.startsWith('http') ? redirectUrl : `${urlObj.protocol}//${urlObj.host}${redirectUrl}`
|
||
this.doDownloadRaw(full, cacheKey, cacheDir).then(resolve)
|
||
return
|
||
}
|
||
}
|
||
if (response.statusCode !== 200) { resolve(null); return }
|
||
|
||
const chunks: Buffer[] = []
|
||
response.on('data', (chunk: Buffer) => chunks.push(chunk))
|
||
response.on('end', () => {
|
||
const buffer = Buffer.concat(chunks)
|
||
if (buffer.length === 0) { resolve(null); return }
|
||
const ext = this.isValidImageBuffer(buffer) ? this.getImageExtFromBuffer(buffer) : '.bin'
|
||
const filePath = join(cacheDir, `${cacheKey}${ext}`)
|
||
try {
|
||
fs.writeFileSync(filePath, buffer)
|
||
resolve(filePath)
|
||
} catch { resolve(null) }
|
||
})
|
||
response.on('error', () => resolve(null))
|
||
})
|
||
request.on('error', () => resolve(null))
|
||
request.setTimeout(15000, () => { request.destroy(); resolve(null) })
|
||
} catch { resolve(null) }
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 下载朋友圈评论中的表情包(多种解密算法,移植自 ciphertalk)
|
||
*/
|
||
async downloadSnsEmoji(url: string, encryptUrl?: string, aesKey?: string): Promise<{ success: boolean; localPath?: string; error?: string }> {
|
||
if (!url && !encryptUrl) return { success: false, error: 'url 不能为空' }
|
||
|
||
const fs = require('fs')
|
||
const cacheKey = crypto.createHash('md5').update(url || encryptUrl!).digest('hex')
|
||
const cachePath = this.configService.getCacheBasePath()
|
||
const emojiDir = join(cachePath, 'sns_emoji_cache')
|
||
if (!existsSync(emojiDir)) mkdirSync(emojiDir, { recursive: true })
|
||
|
||
// 检查本地缓存
|
||
for (const ext of ['.gif', '.png', '.webp', '.jpg', '.jpeg']) {
|
||
const filePath = join(emojiDir, `${cacheKey}${ext}`)
|
||
if (existsSync(filePath)) return { success: true, localPath: filePath }
|
||
}
|
||
|
||
// 保存解密后的图片
|
||
const saveDecrypted = (buf: Buffer): { success: boolean; localPath?: string } => {
|
||
const ext = this.isValidImageBuffer(buf) ? this.getImageExtFromBuffer(buf) : '.gif'
|
||
const filePath = join(emojiDir, `${cacheKey}${ext}`)
|
||
try { fs.writeFileSync(filePath, buf); return { success: true, localPath: filePath } }
|
||
catch { return { success: false } }
|
||
}
|
||
|
||
// 1. 优先:encryptUrl + aesKey
|
||
if (encryptUrl && aesKey) {
|
||
const encResult = await this.doDownloadRaw(encryptUrl, cacheKey + '_enc', emojiDir)
|
||
if (encResult) {
|
||
const encData = fs.readFileSync(encResult)
|
||
if (this.isValidImageBuffer(encData)) {
|
||
const ext = this.getImageExtFromBuffer(encData)
|
||
const filePath = join(emojiDir, `${cacheKey}${ext}`)
|
||
fs.writeFileSync(filePath, encData)
|
||
try { fs.unlinkSync(encResult) } catch { }
|
||
return { success: true, localPath: filePath }
|
||
}
|
||
const decrypted = this.decryptEmojiAes(encData, aesKey)
|
||
if (decrypted) {
|
||
try { fs.unlinkSync(encResult) } catch { }
|
||
return saveDecrypted(decrypted)
|
||
}
|
||
try { fs.unlinkSync(encResult) } catch { }
|
||
}
|
||
}
|
||
|
||
// 2. 直接下载 url
|
||
if (url) {
|
||
const result = await this.doDownloadRaw(url, cacheKey, emojiDir)
|
||
if (result) {
|
||
const buf = fs.readFileSync(result)
|
||
if (this.isValidImageBuffer(buf)) return { success: true, localPath: result }
|
||
// 用 aesKey 解密
|
||
if (aesKey) {
|
||
const decrypted = this.decryptEmojiAes(buf, aesKey)
|
||
if (decrypted) {
|
||
try { fs.unlinkSync(result) } catch { }
|
||
return saveDecrypted(decrypted)
|
||
}
|
||
}
|
||
try { fs.unlinkSync(result) } catch { }
|
||
}
|
||
}
|
||
|
||
return { success: false, error: '下载表情包失败' }
|
||
}
|
||
}
|
||
|
||
export const snsService = new SnsService()
|