Files
CipherTalk/electron/services/videoService.ts
T
ILoveBingLu 14b41e9d4e fix: 增强视频定位能力,支持从 rawContent 提取备用 MD5
问题:部分视频消息的 videoMd5 字段为空或与实际文件名不匹配,
导致视频显示「不可用」。

变更内容:

electron/services/videoService.ts
- getVideoInfo 新增 rawContent 参数,从消息原始 XML 中提取
  newmd5 / rawmd5 等备用 MD5 字段作为候选
- 返回 diagnostics 诊断信息(candidateMd5s、matchedMd5、reason、summary 等),
  便于前端展示失败原因和调试

electron/services/chatService.ts
- extractVideoMd5 新增对 newmd5 / rawmd5 XML 字段的提取支持

electron/services/exportService.ts / httpApiFacade.ts / httpApiService.ts
- getVideoInfo 调用处透传 rawContent 参数

electron/services/mcp/readService.ts
- getVideoLocalPath 支持 rawContent 为空时 videoMd5 也为空的情况

electron/services/imageDecryptService.ts
- 新增 hdNotFoundCache,避免高清图重复查询

electron/main.ts / electron/preload.ts / src/types/electron.d.ts
- IPC 接口同步更新,透传 rawContent 和 diagnostics

src/pages/ChatPage.tsx
- 视频缓存 key 改为 videoMd5 || local:localId,兼容无 MD5 的消息
- 视频不可用时展示 diagnostics.summary 诊断文案
- 详情面板新增关闭动画(closing 状态 + 220ms 延迟)
- 视频播放按钮图标调整

src/pages/ChatPage.scss
- 详情面板改为绝对定位浮层,新增 slideOutRight 关闭动画
- 工具栏按钮改为圆形,悬停加 scale 效果
- 视频不可用区域新增 .video-reason 样式

src/pages/VideoWindow.tsx
- 播放图标尺寸调整
2026-04-21 21:35:22 +08:00

826 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { dirname, join } from 'path'
import { existsSync, readdirSync, statSync, readFileSync, mkdirSync, createWriteStream } from 'fs'
import { writeFile } from 'fs/promises'
import { ConfigService } from './config'
import { getDefaultCachePath as getPlatformDefaultCachePath } from './platformService'
import Database from 'better-sqlite3'
import { Isaac64 } from './isaac64'
import https from 'https'
import http from 'http'
import { getDocumentsPath, getExePath } from './runtimePaths'
export interface VideoInfo {
videoUrl?: string // 视频文件路径(用?readFile?
coverUrl?: string // 封面 data URL
thumbUrl?: string // 缩略?data URL
exists: boolean
diagnostics?: VideoLookupDiagnostics
}
export interface VideoLookupDiagnostics {
requestedMd5?: string
candidateMd5s?: string[]
searchedFileKeys?: string[]
matchedMd5?: string
hardlinkMatchedMd5?: string
hardlinkDbPath?: string
accountDir?: string
videoBaseDir?: string
reason?: 'missing_input' | 'missing_config' | 'account_dir_not_found' | 'video_dir_missing' | 'local_file_missing'
summary?: string
}
export interface ChannelVideoInfo {
objectId: string
title: string
author: string
avatar?: string
videoUrl: string
thumbUrl?: string
coverUrl?: string
duration?: number
width?: number
height?: number
decodeKey?: string
}
export interface DownloadProgress {
downloaded: number
total: number
percentage: number
}
export interface DownloadResult {
success: boolean
filePath?: string
error?: string
needsKey?: boolean // 是否需要解密 key
}
class VideoService {
private configService: ConfigService
constructor() {
this.configService = new ConfigService()
}
private logVideoLookup(stage: string, payload: Record<string, unknown> = {}): void {
console.log(`[VideoLookup] ${stage}`, payload)
}
private warnVideoLookup(stage: string, payload: Record<string, unknown> = {}): void {
console.warn(`[VideoLookup] ${stage}`, payload)
}
private previewRawContent(content?: string): string | undefined {
if (!content) return undefined
const normalized = content.replace(/\s+/g, ' ').trim()
if (!normalized) return undefined
return normalized.slice(0, 220)
}
/**
* 获取数据库根目录
*/
private getDbPath(): string {
return this.configService.get('dbPath') || ''
}
/**
* 获取当前用户的wxid
*/
private getMyWxid(): string {
return this.configService.get('myWxid') || ''
}
/**
* 获取缓存目录(解密后的数据库存放位置? */
private getCachePath(): string {
const cachePath = this.configService.get('cachePath')
if (cachePath) return cachePath
return this.getDefaultCachePath()
}
private getDefaultCachePath(): string {
return getPlatformDefaultCachePath()
}
/**
* 清理 wxid 目录名(去掉后缀?
*/
private cleanWxid(wxid: string): string {
const trimmed = wxid.trim()
if (!trimmed) return trimmed
if (trimmed.toLowerCase().startsWith('wxid_')) {
const match = trimmed.match(/^(wxid_[^_]+)/i)
if (match) return match[1]
return trimmed
}
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
if (suffixMatch) return suffixMatch[1]
return trimmed
}
private normalizeMd5(value?: string | null): string | undefined {
const normalized = String(value || '').trim().toLowerCase()
if (!normalized || !/^[a-f0-9]{8,}$/.test(normalized)) return undefined
return normalized
}
private addMd5Candidate(candidates: string[], value?: string | null): void {
const normalized = this.normalizeMd5(value)
if (!normalized || candidates.includes(normalized)) return
candidates.push(normalized)
}
private extractVideoMsgAttribute(content: string, attrName: string): string | undefined {
const match = new RegExp(`<videomsg[^>]*\\s${attrName}\\s*=\\s*['"]([a-fA-F0-9]+)['"]`, 'i').exec(content)
return this.normalizeMd5(match?.[1])
}
private extractVideoXmlValue(content: string, tagName: string): string | undefined {
const match = new RegExp(`<${tagName}>\\s*([a-fA-F0-9]+)\\s*<\\/${tagName}>`, 'i').exec(content)
return this.normalizeMd5(match?.[1])
}
private collectVideoMd5Candidates(content?: string, preferredMd5?: string): string[] {
const candidates: string[] = []
this.addMd5Candidate(candidates, preferredMd5)
if (!content) return candidates
this.addMd5Candidate(candidates, this.extractVideoMsgAttribute(content, 'newmd5'))
this.addMd5Candidate(candidates, this.extractVideoMsgAttribute(content, 'md5'))
this.addMd5Candidate(candidates, this.extractVideoMsgAttribute(content, 'rawmd5'))
this.addMd5Candidate(candidates, this.extractVideoXmlValue(content, 'newmd5'))
this.addMd5Candidate(candidates, this.extractVideoXmlValue(content, 'md5'))
this.addMd5Candidate(candidates, this.extractVideoXmlValue(content, 'rawmd5'))
return candidates
}
private formatMd5CandidateSummary(values: string[]): string {
return values
.filter(Boolean)
.slice(0, 3)
.map(value => value.slice(0, 8))
.join(' / ')
}
private buildLookupSummary(
reason: VideoLookupDiagnostics['reason'],
diagnostics: Pick<VideoLookupDiagnostics, 'candidateMd5s' | 'searchedFileKeys' | 'hardlinkMatchedMd5'>
): string {
switch (reason) {
case 'missing_input':
return '缺少视频 MD5'
case 'missing_config':
return '缺少数据库路径或微信账号'
case 'account_dir_not_found':
return '未找到账号目录'
case 'video_dir_missing':
return '未找到 msg/video 目录'
case 'local_file_missing': {
if (diagnostics.hardlinkMatchedMd5) {
const resolved = this.formatMd5CandidateSummary(diagnostics.searchedFileKeys || [])
return resolved ? `hardlink 已命中,但本地文件缺失 ${resolved}` : 'hardlink 已命中,但本地文件缺失'
}
const summary = this.formatMd5CandidateSummary(diagnostics.candidateMd5s || [])
return summary ? `未命中本地缓存,候选 ${summary}` : '未命中本地缓存'
}
default:
return '视频不可用'
}
}
private isDirectory(path: string): boolean {
try {
return statSync(path).isDirectory()
} catch {
return false
}
}
private isAccountDir(path: string): boolean {
return (
existsSync(join(path, 'msg')) ||
existsSync(join(path, 'db_storage')) ||
existsSync(join(path, 'hardlink.db'))
)
}
private resolveAccountDir(dbPath: string, wxid: string): string | null {
const normalized = dbPath.replace(/[\\/]+$/, '')
const cleanedWxid = this.cleanWxid(wxid)
const directCandidates = new Set<string>([
normalized,
join(normalized, wxid)
])
if (cleanedWxid !== wxid) {
directCandidates.add(join(normalized, cleanedWxid))
}
for (const candidate of directCandidates) {
if (this.isAccountDir(candidate)) return candidate
}
if (!this.isDirectory(normalized)) return null
try {
const entries = readdirSync(normalized)
const wxidLower = wxid.toLowerCase()
const cleanedWxidLower = cleanedWxid.toLowerCase()
for (const entry of entries) {
const entryPath = join(normalized, entry)
if (!this.isDirectory(entryPath)) continue
const lowerEntry = entry.toLowerCase()
const cleanedEntry = this.cleanWxid(entry).toLowerCase()
if (
lowerEntry === wxidLower ||
lowerEntry === cleanedWxidLower ||
lowerEntry.startsWith(`${wxidLower}_`) ||
lowerEntry.startsWith(`${cleanedWxidLower}_`) ||
cleanedEntry === wxidLower ||
cleanedEntry === cleanedWxidLower
) {
if (this.isAccountDir(entryPath)) return entryPath
}
}
} catch {
// ignore
}
return null
}
/**
* ?video_hardlink_info_v4 表查询视频文件名
*/
private resolveHardlinkDbPath(): string | undefined {
const cachePath = this.getCachePath()
const wxid = this.getMyWxid()
const cleanedWxid = this.cleanWxid(wxid)
const dbPath = this.getDbPath()
if (!cachePath || !wxid) return undefined
// hardlink.db 可能在多个位?
const possiblePaths = new Set<string>([
join(cachePath, cleanedWxid, 'hardlink.db'),
join(cachePath, wxid, 'hardlink.db'),
join(cachePath, 'hardlink.db'),
join(cachePath, 'databases', cleanedWxid, 'hardlink.db'),
join(cachePath, 'databases', wxid, 'hardlink.db')
])
if (dbPath) {
const baseCandidates = new Set<string>([
dbPath,
join(dbPath, wxid),
join(dbPath, cleanedWxid)
])
for (const base of baseCandidates) {
possiblePaths.add(join(base, 'hardlink.db'))
possiblePaths.add(join(base, 'msg', 'hardlink.db'))
}
}
let hardlinkDbPath: string | undefined
for (const p of possiblePaths) {
if (existsSync(p)) {
hardlinkDbPath = p
break
}
}
return hardlinkDbPath
}
private queryVideoFileNames(md5Candidates: string[]): {
fileKeys: string[]
hardlinkDbPath?: string
hardlinkMatchedMd5?: string
} {
const hardlinkDbPath = this.resolveHardlinkDbPath()
if (!hardlinkDbPath || md5Candidates.length === 0) {
return { fileKeys: [], hardlinkDbPath }
}
let db: Database.Database | null = null
try {
db = new Database(hardlinkDbPath, { readonly: true })
const stmt = db.prepare(`
SELECT file_name, md5 FROM video_hardlink_info_v4
WHERE md5 = ?
LIMIT 1
`)
const fileKeys: string[] = []
let hardlinkMatchedMd5: string | undefined
for (const md5 of md5Candidates) {
const row = stmt.get(md5) as { file_name?: string; md5?: string } | undefined
const normalizedFileKey = this.normalizeMd5(row?.file_name?.replace(/\.[^.]+$/, ''))
if (!normalizedFileKey) continue
if (!hardlinkMatchedMd5) {
hardlinkMatchedMd5 = this.normalizeMd5(row?.md5) || md5
}
this.addMd5Candidate(fileKeys, normalizedFileKey)
}
this.logVideoLookup('hardlink-query', {
hardlinkDbPath,
md5Candidates,
fileKeys,
hardlinkMatchedMd5
})
return { fileKeys, hardlinkDbPath, hardlinkMatchedMd5 }
} catch (error) {
this.warnVideoLookup('hardlink-query-error', {
hardlinkDbPath,
md5Candidates,
error: String(error)
})
return { fileKeys: [], hardlinkDbPath }
} finally {
db?.close()
}
}
/**
* 将文件转换为 data URL
*/
private fileToDataUrl(filePath: string, mimeType: string): string | undefined {
try {
if (!existsSync(filePath)) return undefined
const buffer = readFileSync(filePath)
return `data:${mimeType};base64,${buffer.toString('base64')}`
} catch {
return undefined
}
}
/**
* 根据视频MD5获取视频文件信息
* 视频存放? {数据库根目录}/{用户wxid}/msg/video/{年月}/
* 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg
*/
getVideoInfo(videoMd5: string, rawContent?: string): VideoInfo {
const dbPath = this.getDbPath()
const wxid = this.getMyWxid()
const requestedMd5 = this.normalizeMd5(videoMd5)
const candidateMd5s = this.collectVideoMd5Candidates(rawContent, requestedMd5)
const diagnostics: VideoLookupDiagnostics = {
requestedMd5,
candidateMd5s
}
this.logVideoLookup('request', {
requestedMd5,
candidateMd5s,
hasRawContent: Boolean(rawContent),
rawPreview: this.previewRawContent(rawContent),
dbPath,
wxid
})
if (candidateMd5s.length === 0) {
diagnostics.reason = 'missing_input'
diagnostics.summary = this.buildLookupSummary('missing_input', diagnostics)
this.warnVideoLookup('missing-input', diagnostics)
return { exists: false, diagnostics }
}
if (!dbPath || !wxid) {
diagnostics.reason = 'missing_config'
diagnostics.summary = this.buildLookupSummary('missing_config', diagnostics)
this.warnVideoLookup('missing-config', diagnostics)
return { exists: false, diagnostics }
}
const accountDir = this.resolveAccountDir(dbPath, wxid)
diagnostics.accountDir = accountDir || undefined
this.logVideoLookup('account-dir', {
requestedMd5,
accountDir,
dbPath,
wxid
})
if (!accountDir) {
diagnostics.reason = 'account_dir_not_found'
diagnostics.summary = this.buildLookupSummary('account_dir_not_found', diagnostics)
this.warnVideoLookup('account-dir-not-found', diagnostics)
return { exists: false, diagnostics }
}
const videoBaseDir = join(accountDir, 'msg', 'video')
diagnostics.videoBaseDir = videoBaseDir
const hardlinkResult = this.queryVideoFileNames(candidateMd5s)
diagnostics.hardlinkDbPath = hardlinkResult.hardlinkDbPath
diagnostics.hardlinkMatchedMd5 = hardlinkResult.hardlinkMatchedMd5
const fileKeys = [...candidateMd5s]
for (const fileKey of hardlinkResult.fileKeys) {
this.addMd5Candidate(fileKeys, fileKey)
}
diagnostics.searchedFileKeys = fileKeys
this.logVideoLookup('search-plan', {
requestedMd5,
candidateMd5s,
hardlinkDbPath: diagnostics.hardlinkDbPath,
hardlinkMatchedMd5: diagnostics.hardlinkMatchedMd5,
searchedFileKeys: diagnostics.searchedFileKeys,
videoBaseDir
})
if (!existsSync(videoBaseDir)) {
diagnostics.reason = 'video_dir_missing'
diagnostics.summary = this.buildLookupSummary('video_dir_missing', diagnostics)
this.warnVideoLookup('video-dir-missing', diagnostics)
return { exists: false, diagnostics }
}
// 遍历年月目录查找视频文件
try {
const allDirs = readdirSync(videoBaseDir)
// 支持多种目录格式: YYYY-MM, YYYYMM, 或其?
const yearMonthDirs = allDirs
.filter(dir => {
const dirPath = join(videoBaseDir, dir)
return statSync(dirPath).isDirectory()
})
.sort((a, b) => b.localeCompare(a)) // 从最新的目录开始查?
for (const yearMonth of yearMonthDirs) {
const dirPath = join(videoBaseDir, yearMonth)
for (const fileKey of fileKeys) {
const videoPath = join(dirPath, `${fileKey}.mp4`)
const coverPath = join(dirPath, `${fileKey}.jpg`)
const thumbPath = join(dirPath, `${fileKey}_thumb.jpg`)
// 检查视频文件是否存?
if (existsSync(videoPath)) {
diagnostics.matchedMd5 = fileKey
this.logVideoLookup('local-file-hit', {
requestedMd5,
matchedMd5: fileKey,
yearMonth,
videoPath,
coverExists: existsSync(coverPath),
thumbExists: existsSync(thumbPath)
})
return {
videoUrl: `file:///${videoPath.replace(/\\/g, '/')}`, // 转换为 file:// 协议
coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'),
thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'),
exists: true,
diagnostics
}
}
}
}
} catch (error) {
this.warnVideoLookup('scan-error', {
requestedMd5,
videoBaseDir,
searchedFileKeys: diagnostics.searchedFileKeys,
error: String(error)
})
}
diagnostics.reason = 'local_file_missing'
diagnostics.summary = this.buildLookupSummary('local_file_missing', diagnostics)
this.warnVideoLookup('local-file-missing', diagnostics)
return { exists: false, diagnostics }
}
/**
* 根据消息内容解析视频MD5
*/
parseVideoMd5(content: string): string | undefined {
if (!content) return undefined
try {
return (
this.extractVideoXmlValue(content, 'md5') ||
this.extractVideoMsgAttribute(content, 'md5') ||
this.extractVideoXmlValue(content, 'newmd5') ||
this.extractVideoMsgAttribute(content, 'newmd5') ||
this.extractVideoXmlValue(content, 'rawmd5') ||
this.extractVideoMsgAttribute(content, 'rawmd5')
)
} catch (e) {
console.error('解析视频MD5失败:', e)
}
return undefined
}
/**
* 从聊天消息 XML 中解析视频号信息
*/
parseChannelVideoFromXml(content: string): ChannelVideoInfo | undefined {
if (!content) return undefined
try {
// 提取 finderFeed 内容
const finderMatch = /<finderFeed>([\s\S]*?)<\/finderFeed>/i.exec(content)
if (!finderMatch) return undefined
const finderXml = finderMatch[1]
// 提取基本信息
const objectIdMatch = /<objectId>[\s\S]*?<!\[CDATA\[(.*?)\]\]>[\s\S]*?<\/objectId>/i.exec(finderXml)
const nicknameMatch = /<nickname>[\s\S]*?<!\[CDATA\[(.*?)\]\]>[\s\S]*?<\/nickname>/i.exec(finderXml)
const descMatch = /<desc>[\s\S]*?<!\[CDATA\[(.*?)\]\]>[\s\S]*?<\/desc>/i.exec(finderXml)
const avatarMatch = /<avatar>[\s\S]*?<!\[CDATA\[(.*?)\]\]>[\s\S]*?<\/avatar>/i.exec(finderXml)
if (!objectIdMatch) return undefined
const objectId = objectIdMatch[1]
const author = nicknameMatch ? nicknameMatch[1] : '未知作者'
const title = descMatch ? descMatch[1] : '视频号视频'
const avatar = avatarMatch ? avatarMatch[1] : undefined
// 提取媒体信息
const mediaListMatch = /<mediaList>([\s\S]*?)<\/mediaList>/i.exec(finderXml)
if (!mediaListMatch) return undefined
const mediaXml = mediaListMatch[1]
const urlMatch = /<url>[\s\S]*?<!\[CDATA\[(.*?)\]\]>[\s\S]*?<\/url>/i.exec(mediaXml)
const thumbUrlMatch = /<thumbUrl>[\s\S]*?<!\[CDATA\[(.*?)\]\]>[\s\S]*?<\/thumbUrl>/i.exec(mediaXml)
const coverUrlMatch = /<coverUrl>[\s\S]*?<!\[CDATA\[(.*?)\]\]>[\s\S]*?<\/coverUrl>/i.exec(mediaXml)
const durationMatch = /<videoPlayDuration>[\s\S]*?<!\[CDATA\[(\d+)\]\]>[\s\S]*?<\/videoPlayDuration>/i.exec(mediaXml)
const widthMatch = /<width>[\s\S]*?<!\[CDATA\[(\d+)\]\]>[\s\S]*?<\/width>/i.exec(mediaXml)
const heightMatch = /<height>[\s\S]*?<!\[CDATA\[(\d+)\]\]>[\s\S]*?<\/height>/i.exec(mediaXml)
const decodeKeyMatch = /<decodeKey>[\s\S]*?<!\[CDATA\[(.*?)\]\]>[\s\S]*?<\/decodeKey>/i.exec(mediaXml)
if (!urlMatch) return undefined
return {
objectId,
title,
author,
avatar,
videoUrl: urlMatch[1],
thumbUrl: thumbUrlMatch ? thumbUrlMatch[1] : undefined,
coverUrl: coverUrlMatch ? coverUrlMatch[1] : undefined,
duration: durationMatch ? parseInt(durationMatch[1]) : undefined,
width: widthMatch ? parseInt(widthMatch[1]) : undefined,
height: heightMatch ? parseInt(heightMatch[1]) : undefined,
decodeKey: decodeKeyMatch ? decodeKeyMatch[1] : undefined
}
} catch (e) {
console.error('解析视频号信息失败:', e)
return undefined
}
}
/**
* 下载视频号视频
* @param videoInfo 视频信息
* @param key 解密密钥(可选)
* @param onProgress 进度回调
*/
async downloadChannelVideo(
videoInfo: ChannelVideoInfo,
key?: string,
onProgress?: (progress: DownloadProgress) => void
): Promise<DownloadResult> {
try {
console.log('[ChannelVideo] 开始下载:', videoInfo.objectId)
console.log('[ChannelVideo] 完整URL:', videoInfo.videoUrl)
if (!videoInfo.videoUrl) {
console.error('[ChannelVideo] videoUrl 为空')
return { success: false, error: '视频地址为空' }
}
// 创建下载目录
const cachePath = this.getCachePath()
const channelDir = join(cachePath, 'channel_videos', this.sanitizeFilename(videoInfo.author))
if (!existsSync(channelDir)) {
mkdirSync(channelDir, { recursive: true })
}
// 生成文件名
const filename = `${this.sanitizeFilename(videoInfo.title)}_${videoInfo.objectId}.mp4`
const filePath = join(channelDir, filename)
// 检查文件是否已存在
if (existsSync(filePath)) {
return {
success: true,
filePath
}
}
// 下载视频
const tempPath = filePath + '.tmp'
const downloaded = await this.downloadFile(videoInfo.videoUrl, tempPath, onProgress)
if (downloaded !== true) {
const msg = downloaded === 400 || downloaded === 403 ? '链接已过期,无法下载' : '下载失败'
return { success: false, error: msg }
}
// 检查下载的文件大小
const stat = require('fs').statSync(tempPath)
console.log('[ChannelVideo] 下载完成, 文件大小:', stat.size)
// TODO: 后续实现解密(需要通过 JS Hook 获取 decodeKey
// 重命名为最终文件
require('fs').renameSync(tempPath, filePath)
return {
success: true,
filePath
}
} catch (e: any) {
console.error('下载视频号视频失败:', e)
return {
success: false,
error: e.message || '下载失败'
}
}
}
/**
* 下载文件
*/
private downloadFile(
url: string,
destPath: string,
onProgress?: (progress: DownloadProgress) => void
): Promise<true | number> {
return new Promise((resolve) => {
const doRequest = (currentUrl: string, redirectsLeft: number) => {
try {
console.log('[ChannelVideo] 请求URL:', currentUrl.substring(0, 120), '剩余重定向:', redirectsLeft)
const parsedUrl = new URL(currentUrl)
const reqPath = parsedUrl.pathname + parsedUrl.search
console.log('[ChannelVideo] 解析path长度:', reqPath.length, 'search长度:', parsedUrl.search.length)
const proto = parsedUrl.protocol === 'https:' ? https : http
const reqOptions = {
hostname: parsedUrl.hostname,
port: parsedUrl.port,
path: parsedUrl.pathname + parsedUrl.search,
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) NetType/WIFI MiniProgramEnv/Windows WindowsWechat',
'Referer': 'https://channels.weixin.qq.com/'
}
}
proto.get(reqOptions, (response) => {
console.log('[ChannelVideo] 响应状态:', response.statusCode)
// 处理重定向
if ([301, 302, 303, 307, 308].includes(response.statusCode!) && response.headers.location) {
if (redirectsLeft <= 0) {
console.error('重定向次数过多')
resolve(0)
return
}
doRequest(response.headers.location, redirectsLeft - 1)
return
}
if (response.statusCode !== 200) {
console.error('下载失败,状态码:', response.statusCode)
resolve(response.statusCode || 0)
return
}
const totalSize = parseInt(response.headers['content-length'] || '0', 10)
let downloadedSize = 0
const fileStream = createWriteStream(destPath)
response.on('data', (chunk) => {
downloadedSize += chunk.length
if (onProgress && totalSize > 0) {
onProgress({
downloaded: downloadedSize,
total: totalSize,
percentage: (downloadedSize / totalSize) * 100
})
}
})
response.pipe(fileStream)
fileStream.on('finish', () => {
fileStream.close()
resolve(true)
})
fileStream.on('error', (err) => {
console.error('写入文件失败:', err)
fileStream.close()
resolve(0)
})
}).on('error', (err) => {
console.error('下载请求失败:', err)
resolve(0)
})
} catch (e) {
console.error('下载异常:', e)
resolve(0)
}
}
doRequest(url, 5)
})
}
/**
* 检查视频是否加密
* 通过检查文件头部特征判断
*/
private async checkIfEncrypted(filePath: string): Promise<boolean> {
try {
const fd = require('fs').openSync(filePath, 'r')
const header = Buffer.alloc(12)
require('fs').readSync(fd, header, 0, 12, 0)
require('fs').closeSync(fd)
const sig = header.toString('ascii', 4, 8)
console.log('[ChannelVideo] 文件头签名:', sig, '前12字节hex:', header.toString('hex'))
// MP4 box types that indicate a valid video file
if (['ftyp', 'mdat', 'moov', 'free', 'skip', 'wide'].includes(sig)) {
return false
}
// 也检查前4字节是否是常见视频格式
const head4 = header.toString('hex', 0, 4)
if (head4 === '1a45dfa3' || head4 === '464c5601') { // WebM / FLV
return false
}
return true
} catch (e) {
console.error('检查加密状态失败:', e)
return false
}
}
/**
* 使用 ISAAC64 解密视频号视频
* 只解密前 128KB
*/
private async decryptChannelVideo(filePath: string, key: string): Promise<boolean> {
try {
const buffer = readFileSync(filePath)
const prefixLen = 131072 // 128KB
if (buffer.length === 0) return false
// 生成解密密钥流
const isaac = new Isaac64(key)
const keystream = isaac.generateKeystreamBE(Math.min(prefixLen, buffer.length))
// XOR 解密前 128KB
const decryptLen = Math.min(prefixLen, buffer.length)
for (let i = 0; i < decryptLen; i++) {
buffer[i] ^= keystream[i]
}
// 写回文件
await writeFile(filePath, buffer)
return true
} catch (e) {
console.error('解密视频失败:', e)
return false
}
}
/**
* 清理文件名中的非法字符
*/
private sanitizeFilename(filename: string): string {
return filename
.replace(/[<>:"/\\|?*]/g, '_') // 替换非法字符
.replace(/\s+/g, '_') // 替换空格
.substring(0, 100) // 限制长度
}
}
export const videoService = new VideoService()