Files
WeChat-Channels-Video-File-…/api-service/server.js
T
Evil0ctal 8900a7c6fb fix: 修复大文件解密崩溃并将 XOR 解密移至服务端
问题修复:
- 修复解密超过 80MB 视频时页面崩溃的问题(issue #4 #6 #8)
- 根因:旧实现将整段视频经 CDP 管道传入浏览器做 XOR,base64 膨胀后超过
  Chromium DevTools 协议 100MB 单条消息上限,导致页面被关闭
- 将 XOR 解密从浏览器移至 Node.js 端,浏览器仅负责生成 128KB 密钥流,
  视频数据不再进入浏览器,文件大小不再受此限制

新功能:
- 新增 lib/decrypt.js 解密核心模块(decryptBuffer / assertMp4)
- 新增 decode_key 密钥流 LRU 缓存,命中后跳过浏览器 WASM 调用,
  可通过 KEYSTREAM_CACHE_MAX 环境变量配置上限(默认100)
- 新增 test/decrypt.test.js 单元测试,覆盖 120MB 大文件边界场景

其他:
- Dockerfile 补充拷贝 lib 目录,修复容器内模块缺失
- worker.html 的 decryptVideo 标注为已弃用(保留向后兼容)
- 更新 README 架构说明,补充大文件故障排除与「如何获取 decode_key」说明
- 版本号升级至 2.1.0
2026-06-09 22:33:15 -07:00

853 lines
26 KiB
JavaScript
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.
/**
* WeChat Channels Video Decryption API Service
*
* 基于 Playwright 浏览器自动化的 RPC 解密服务
* 通过真实浏览器环境完美兼容微信官方 WASM 模块
*
* @author Evil0ctal
* @license MIT
*/
const express = require('express');
const { chromium } = require('playwright');
const multer = require('multer');
const cors = require('cors');
const path = require('path');
const { KEYSTREAM_SIZE, decryptBuffer, assertMp4 } = require('./lib/decrypt');
const app = express();
const PORT = process.env.PORT || 3000;
// 配置文件上传
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 500 * 1024 * 1024 } // 500MB
});
// 中间件
app.use(cors());
app.use(express.json({ limit: '100mb' }));
// 静态文件服务 - 提供 wechat_files 目录
app.use('/wechat_files', express.static(path.join(__dirname, 'wechat_files')));
// 全局变量
let browser = null;
let page = null;
let server = null;
let isProcessing = false; // 请求锁
let requestQueue = []; // 请求队列
const getWorkerUrl = () => `http://localhost:${PORT}/worker.html`;
// 页面池配置
const POOL_SIZE = parseInt(process.env.POOL_SIZE) || 3; // 默认3个并发页面
let pagePool = null;
// 解密配置(KEYSTREAM_SIZE / decryptBuffer / assertMp4 见 ./lib/decrypt)
// 密钥流缓存:同一 decode_key 生成的密钥流恒定,可复用以跳过浏览器 WASM 调用
// 采用简单 LRU(依赖 Map 的插入顺序),上限可通过环境变量配置
const KEYSTREAM_CACHE_MAX = parseInt(process.env.KEYSTREAM_CACHE_MAX) || 100;
const keystreamCache = new Map();
/**
* 页面池管理器 - 支持并发处理
*/
class PagePool {
constructor(browser, size) {
this.browser = browser;
this.size = size;
this.pages = []; // 所有页面
this.available = []; // 可用页面队列
this.waiting = []; // 等待获取页面的请求
this.initializing = false;
}
/**
* 初始化页面池
*/
async initialize() {
if (this.initializing) return;
this.initializing = true;
console.log(`🏊 初始化页面池 (大小: ${this.size})...`);
for (let i = 0; i < this.size; i++) {
try {
const pg = await this._createPage(i);
this.pages.push(pg);
this.available.push(pg);
console.log(` ✅ 页面 ${i + 1}/${this.size} 已就绪`);
} catch (error) {
console.error(` ❌ 页面 ${i + 1}/${this.size} 创建失败:`, error.message);
}
}
console.log(`🏊 页面池初始化完成,可用页面: ${this.available.length}/${this.size}`);
this.initializing = false;
}
/**
* 创建单个页面
*/
async _createPage(index) {
const pg = await this.browser.newPage();
pg._poolIndex = index;
await pg.goto(getWorkerUrl());
// 等待 WASM 加载
await pg.waitForFunction(
() => typeof Module !== 'undefined' && typeof Module.WxIsaac64 !== 'undefined',
{ timeout: 60000 }
);
return pg;
}
/**
* 获取一个可用页面(如果没有则等待)
*/
async acquire(timeout = 300000) {
// 如果有可用页面,直接返回
if (this.available.length > 0) {
const pg = this.available.shift();
console.log(`🔓 获取页面 #${pg._poolIndex},剩余可用: ${this.available.length}`);
return pg;
}
// 没有可用页面,加入等待队列
console.log(`⏳ 页面池已满,等待中... (等待队列: ${this.waiting.length + 1})`);
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
const index = this.waiting.findIndex(w => w.resolve === resolve);
if (index > -1) {
this.waiting.splice(index, 1);
}
reject(new Error(`获取页面超时 (${timeout}ms),请稍后重试`));
}, timeout);
this.waiting.push({
resolve: (pg) => {
clearTimeout(timer);
resolve(pg);
},
reject
});
});
}
/**
* 归还页面到池中
*/
release(pg) {
// 检查页面是否有效
if (!pg || pg.isClosed()) {
console.log(`⚠️ 页面 #${pg?._poolIndex} 已关闭,将重建`);
this._rebuildPage(pg?._poolIndex);
return;
}
// 如果有等待的请求,直接给它
if (this.waiting.length > 0) {
const waiter = this.waiting.shift();
console.log(`🔄 页面 #${pg._poolIndex} 直接分配给等待的请求`);
waiter.resolve(pg);
return;
}
// 否则放回可用队列
this.available.push(pg);
console.log(`🔙 归还页面 #${pg._poolIndex},可用: ${this.available.length}`);
}
/**
* 重建损坏的页面
*/
async _rebuildPage(index) {
if (index === undefined) return;
try {
console.log(`🔄 重建页面 #${index}...`);
const pg = await this._createPage(index);
this.pages[index] = pg;
// 如果有等待的请求,直接给它
if (this.waiting.length > 0) {
const waiter = this.waiting.shift();
waiter.resolve(pg);
} else {
this.available.push(pg);
}
console.log(`✅ 页面 #${index} 重建完成`);
} catch (error) {
console.error(`❌ 页面 #${index} 重建失败:`, error.message);
}
}
/**
* 获取池状态
*/
getStatus() {
return {
total: this.size,
available: this.available.length,
inUse: this.size - this.available.length,
waiting: this.waiting.length
};
}
/**
* 关闭所有页面
*/
async close() {
for (const pg of this.pages) {
try {
if (pg && !pg.isClosed()) {
await pg.close();
}
} catch (e) {
// 忽略关闭错误
}
}
this.pages = [];
this.available = [];
}
}
/**
* 使用页面池执行任务
*/
async function withPoolPage(fn) {
if (!pagePool) {
throw new Error('页面池未初始化');
}
const pg = await pagePool.acquire();
try {
return await fn(pg);
} catch (error) {
// 如果是页面关闭错误,标记页面需要重建
if (error.message && error.message.includes('closed')) {
console.log(`⚠️ 页面 #${pg._poolIndex} 执行出错,将重建`);
pagePool._rebuildPage(pg._poolIndex);
throw error;
}
throw error;
} finally {
pagePool.release(pg);
}
}
/**
* 带超时的 page.evaluate (用于页面池)
*/
async function evaluateWithTimeoutOnPage(pg, pageFunction, arg, timeout = 30000) {
return Promise.race([
pg.evaluate(pageFunction, arg),
new Promise((_, reject) =>
setTimeout(() => reject(new Error(`操作超时 (${timeout}ms)`)), timeout)
)
]);
}
/**
* 检查页面是否健康可用
*/
async function isPageHealthy() {
if (!browser || !page) {
return false;
}
try {
// 尝试执行简单操作来检查页面是否有效(带5秒超时)
await Promise.race([
page.evaluate(() => true),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('健康检查超时')), 5000)
)
]);
return true;
} catch (error) {
console.log('⚠️ 页面健康检查失败:', error.message);
return false;
}
}
/**
* 重置浏览器和页面
*/
async function resetBrowser() {
console.log('🔄 重置浏览器...');
// 先尝试关闭旧的浏览器
if (browser) {
try {
await browser.close();
} catch (e) {
// 忽略关闭错误
}
}
browser = null;
page = null;
// 重新初始化
await initBrowser();
console.log('✅ 浏览器重置完成');
}
/**
* 确保页面可用,如果不可用则重置
*/
async function ensurePageReady() {
if (!await isPageHealthy()) {
await resetBrowser();
}
}
/**
* 带锁的执行函数,确保同一时间只有一个请求在使用页面
*/
async function withPageLock(fn) {
return new Promise((resolve, reject) => {
const execute = async () => {
isProcessing = true;
try {
await ensurePageReady();
const result = await fn();
resolve(result);
} catch (error) {
// 如果是页面关闭相关错误,标记需要重置
if (error.message && error.message.includes('closed')) {
console.log('⚠️ 检测到页面已关闭,将在下次请求时重置');
browser = null;
page = null;
}
reject(error);
} finally {
isProcessing = false;
// 处理队列中的下一个请求
if (requestQueue.length > 0) {
const next = requestQueue.shift();
next();
}
}
};
if (isProcessing) {
// 如果正在处理,加入队列等待
console.log('⏳ 请求排队中,当前队列长度:', requestQueue.length + 1);
requestQueue.push(execute);
} else {
execute();
}
});
}
/**
* 带超时的 page.evaluate 包装函数
*/
async function evaluateWithTimeout(pageFunction, arg, timeout = 30000) {
return Promise.race([
page.evaluate(pageFunction, arg),
new Promise((_, reject) =>
setTimeout(() => reject(new Error(`操作超时 (${timeout}ms)`)), timeout)
)
]);
}
// ==================== 密钥流缓存 ====================
/**
* 读取密钥流缓存(命中后移到最近使用位置)
* @param {string|number} decodeKey
* @returns {Buffer|null}
*/
function getCachedKeystream(decodeKey) {
const key = String(decodeKey);
const buf = keystreamCache.get(key);
if (!buf) return null;
keystreamCache.delete(key);
keystreamCache.set(key, buf); // 重新插入到队尾,标记为最近使用
return buf;
}
/**
* 写入密钥流缓存,超出上限时淘汰最久未使用项
* @param {string|number} decodeKey
* @param {Buffer} buffer
*/
function setCachedKeystream(decodeKey, buffer) {
keystreamCache.set(String(decodeKey), buffer);
if (keystreamCache.size > KEYSTREAM_CACHE_MAX) {
const oldest = keystreamCache.keys().next().value;
keystreamCache.delete(oldest);
}
}
/**
* 解析密钥流:优先命中缓存,否则调用浏览器生成并写入缓存
* @param {string|number} decodeKey
* @param {() => Promise<string>} generate 返回 base64 密钥流的生成函数(浏览器侧)
* @returns {Promise<Buffer>} 128KB 密钥流 Buffer
*/
async function resolveKeystream(decodeKey, generate) {
const cached = getCachedKeystream(decodeKey);
if (cached) return cached;
const keystreamBase64 = await generate();
const keystream = Buffer.from(keystreamBase64, 'base64');
setCachedKeystream(decodeKey, keystream);
return keystream;
}
/**
* 初始化 Playwright 浏览器
*/
async function initBrowser() {
if (browser && page) {
return;
}
console.log('🚀 启动 Playwright 浏览器...');
browser = await chromium.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox']
});
page = await browser.newPage();
// 加载 RPC Worker 页面(通过 HTTP 以支持本地 WASM 文件加载)
await page.goto(getWorkerUrl());
// 等待 WASM 模块完全加载 (等待 Module.WxIsaac64 可用)
console.log('⏳ 等待 WASM 模块加载...');
await page.waitForFunction(
() => typeof Module !== 'undefined' && typeof Module.WxIsaac64 !== 'undefined',
{ timeout: 60000 }
);
const status = await page.evaluate(() => window.checkWasmStatus());
console.log(` WASM 模块状态: ${JSON.stringify(status)}`);
// 检查是否使用 CDN
const usingCdn = await page.evaluate(() => window.WASM_USING_CDN);
console.log('✅ Playwright 浏览器已就绪');
console.log(` Worker URL: ${getWorkerUrl()}`);
console.log(` WASM Source: ${usingCdn ? 'WeChat CDN (fallback)' : 'Local files'}`);
console.log(` WASM Status: ${status.loaded ? 'Loaded' : 'Not Loaded'}`);
}
/**
* 请求日志中间件
*/
app.use((req, res, next) => {
console.log(`[${new Date().toISOString()}] ${req.method} ${req.path}`);
next();
});
// ==================== API 路由 ====================
/**
* GET /health
* 健康检查
*/
app.get('/health', async (req, res) => {
try {
if (!page) {
await initBrowser();
}
const wasmStatus = await page.evaluate(() => window.checkWasmStatus());
res.json({
status: 'ok',
service: 'wechat-decrypt-api',
version: '2.1.0',
engine: 'playwright',
wasm: wasmStatus,
timestamp: new Date().toISOString()
});
} catch (error) {
res.status(500).json({
status: 'error',
error: error.message
});
}
});
/**
* GET /
* API 文档页面
*/
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'docs.html'));
});
/**
* GET /worker.html
* RPC Worker 页面(供 Playwright 浏览器加载)
*/
app.get('/worker.html', (req, res) => {
res.sendFile(path.join(__dirname, 'worker.html'));
});
/**
* GET /api/info
* 服务信息(JSON 格式)
*/
app.get('/api/info', (req, res) => {
res.json({
service: 'WeChat Channels Video Decryption API',
version: '2.1.0',
engine: 'Playwright + Chromium',
author: 'Evil0ctal',
github: 'https://github.com/Evil0ctal/WeChat-Channels-Video-File-Decryption',
endpoints: {
health: 'GET /health',
poolStatus: 'GET /api/pool-status',
keystream: 'POST /api/keystream',
decrypt: 'POST /api/decrypt (串行)',
decryptConcurrent: 'POST /api/decrypt-concurrent (并发)'
}
});
});
/**
* POST /api/keystream
* 生成密钥流
*/
app.post('/api/keystream', async (req, res) => {
try {
const { decode_key, format = 'hex' } = req.body;
if (!decode_key) {
return res.status(400).json({ error: '缺少 decode_key 参数' });
}
if (!['hex', 'base64'].includes(format)) {
return res.status(400).json({
error: '无效的 format 参数',
valid_formats: ['hex', 'base64']
});
}
console.log(`🔑 生成密钥流: decode_key=${decode_key}, format=${format}`);
// 使用锁机制确保串行处理
const result = await withPageLock(async () => {
const startTime = Date.now();
// 调用浏览器中的 RPC 方法(带超时保护)
const keystreamBase64 = await evaluateWithTimeout(
async (key) => await window.generateKeystream(key),
decode_key,
30000
);
const duration = Date.now() - startTime;
// 格式转换
let keystream;
if (format === 'hex') {
const binary = atob(keystreamBase64);
keystream = Array.from(binary, c =>
c.charCodeAt(0).toString(16).padStart(2, '0')
).join('');
} else {
keystream = keystreamBase64;
}
console.log(`✅ 密钥流生成成功,耗时 ${duration}ms`);
return {
decode_key,
keystream,
format,
size: KEYSTREAM_SIZE,
duration_ms: duration,
timestamp: new Date().toISOString()
};
});
res.json(result);
} catch (error) {
console.error('❌ 密钥流生成失败:', error.message);
res.status(500).json({ error: error.message });
}
});
/**
* POST /api/decrypt
* 完整解密视频
*/
app.post('/api/decrypt', upload.single('video'), async (req, res) => {
try {
const { decode_key } = req.body;
const videoFile = req.file;
if (!decode_key) {
return res.status(400).json({ error: '缺少 decode_key 参数' });
}
if (!videoFile) {
return res.status(400).json({ error: '缺少视频文件' });
}
console.log(`📹 解密请求:`);
console.log(` decode_key: ${decode_key}`);
console.log(` 文件: ${videoFile.originalname} (${(videoFile.size / 1024 / 1024).toFixed(2)} MB)`);
const startTime = Date.now();
// 密钥流命中缓存时无需浏览器,直接在 Node.js 端解密
const cached = getCachedKeystream(decode_key);
let result;
if (cached) {
console.log(' [1/2] 命中密钥流缓存');
console.log(' [2/2] 执行 XOR 解密...');
const decrypted = decryptBuffer(videoFile.buffer, cached);
assertMp4(decrypted);
result = { decrypted, duration: Date.now() - startTime };
} else {
// 未命中:用锁机制串行调用浏览器生成密钥流,再在 Node.js 端 XOR
result = await withPageLock(async () => {
// 步骤 1: 生成密钥流(浏览器 WASM,仅 128KB,不经 CDP 传大文件)
console.log(' [1/2] 生成密钥流...');
const keystream = await resolveKeystream(decode_key, () =>
evaluateWithTimeout(
async (key) => await window.generateKeystream(key),
decode_key,
30000
)
);
// 步骤 2: 在 Node.js 端执行 XOR 解密(规避 CDP 100MB 限制)
console.log(' [2/2] 执行 XOR 解密...');
const decrypted = decryptBuffer(videoFile.buffer, keystream);
assertMp4(decrypted);
return { decrypted, duration: Date.now() - startTime };
});
}
console.log(`✅ 解密成功,耗时 ${result.duration}ms`);
// 返回解密后的视频
res.set({
'Content-Type': 'video/mp4',
'Content-Length': result.decrypted.length,
'Content-Disposition': `attachment; filename="decrypted_${Date.now()}.mp4"`,
'X-Decrypt-Duration': result.duration
});
res.send(result.decrypted);
} catch (error) {
console.error('❌ 解密失败:', error.message);
res.status(500).json({ error: error.message });
}
});
/**
* POST /api/decrypt-concurrent
* 并发解密视频(使用页面池)
*/
app.post('/api/decrypt-concurrent', upload.single('video'), async (req, res) => {
try {
const { decode_key } = req.body;
const videoFile = req.file;
if (!decode_key) {
return res.status(400).json({ error: '缺少 decode_key 参数' });
}
if (!videoFile) {
return res.status(400).json({ error: '缺少视频文件' });
}
if (!pagePool) {
return res.status(503).json({ error: '页面池未初始化,请稍后重试' });
}
console.log(`📹 [并发] 解密请求:`);
console.log(` decode_key: ${decode_key}`);
console.log(` 文件: ${videoFile.originalname} (${(videoFile.size / 1024 / 1024).toFixed(2)} MB)`);
console.log(` 页面池状态: ${JSON.stringify(pagePool.getStatus())}`);
const startTime = Date.now();
// 密钥流命中缓存时无需占用页面,直接在 Node.js 端解密
const cached = getCachedKeystream(decode_key);
let result;
if (cached) {
console.log(' [并发] 命中密钥流缓存,跳过页面池');
const decrypted = decryptBuffer(videoFile.buffer, cached);
assertMp4(decrypted);
result = { decrypted, duration: Date.now() - startTime };
} else {
// 未命中:用页面池生成密钥流(支持并发),再在 Node.js 端 XOR
result = await withPoolPage(async (pg) => {
// 步骤 1: 生成密钥流(浏览器 WASM,仅 128KB)
console.log(` [页面#${pg._poolIndex}] [1/2] 生成密钥流...`);
const keystream = await resolveKeystream(decode_key, () =>
evaluateWithTimeoutOnPage(
pg,
async (key) => await window.generateKeystream(key),
decode_key,
30000
)
);
// 步骤 2: 在 Node.js 端执行 XOR 解密(规避 CDP 100MB 限制)
console.log(` [页面#${pg._poolIndex}] [2/2] 执行 XOR 解密...`);
const decrypted = decryptBuffer(videoFile.buffer, keystream);
assertMp4(decrypted);
return { decrypted, duration: Date.now() - startTime };
});
}
console.log(`✅ [并发] 解密成功,耗时 ${result.duration}ms`);
// 返回解密后的视频
res.set({
'Content-Type': 'video/mp4',
'Content-Length': result.decrypted.length,
'Content-Disposition': `attachment; filename="decrypted_${Date.now()}.mp4"`,
'X-Decrypt-Duration': result.duration
});
res.send(result.decrypted);
} catch (error) {
console.error('❌ [并发] 解密失败:', error.message);
res.status(500).json({ error: error.message });
}
});
/**
* GET /api/pool-status
* 获取页面池状态
*/
app.get('/api/pool-status', (req, res) => {
if (!pagePool) {
return res.status(503).json({
error: '页面池未初始化',
status: null
});
}
res.json({
status: 'ok',
pool: pagePool.getStatus(),
timestamp: new Date().toISOString()
});
});
/**
* 404 处理
*/
app.use((req, res) => {
res.status(404).json({
error: '接口不存在',
path: req.path,
available: [
'GET /',
'GET /health',
'GET /api/pool-status',
'POST /api/keystream',
'POST /api/decrypt',
'POST /api/decrypt-concurrent'
]
});
});
/**
* 错误处理
*/
app.use((err, req, res, next) => {
console.error('服务器错误:', err);
if (err instanceof multer.MulterError) {
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(413).json({
error: '文件过大',
limit: '500MB'
});
}
}
res.status(500).json({ error: err.message });
});
// ==================== 启动服务 ====================
(async () => {
try {
console.log('╔═══════════════════════════════════════════════════════════╗');
console.log('║ WeChat Channels Video Decryption API (Playwright) ║');
console.log('╚═══════════════════════════════════════════════════════════╝\n');
// 先启动 HTTP 服务器(浏览器需要从这里加载 worker.html)
server = app.listen(PORT, async () => {
console.log('✅ HTTP 服务器已启动');
console.log(`📡 监听端口: ${PORT}`);
console.log(`🌐 访问地址: http://localhost:${PORT}\n`);
try {
// 初始化浏览器(用于串行接口)
await initBrowser();
// 初始化页面池(用于并发接口)
pagePool = new PagePool(browser, POOL_SIZE);
await pagePool.initialize();
console.log('\n✅ 服务完全就绪');
console.log('\n📚 API 端点:');
console.log(' GET / 服务信息');
console.log(' GET /health 健康检查');
console.log(' GET /api/pool-status 页面池状态');
console.log(' POST /api/keystream 生成密钥流');
console.log(' POST /api/decrypt 解密视频 (串行)');
console.log(' POST /api/decrypt-concurrent 解密视频 (并发)');
console.log(`\n🏊 页面池大小: ${POOL_SIZE} (可通过 POOL_SIZE 环境变量配置)`);
console.log('🎭 使用 Playwright 浏览器执行 WASM');
console.log(' 100% 兼容微信官方模块\n');
} catch (error) {
console.error('❌ 浏览器初始化失败:', error);
process.exit(1);
}
});
} catch (error) {
console.error('❌ 启动失败:', error);
process.exit(1);
}
})();
// 优雅关闭
async function gracefulShutdown() {
console.log('\n👋 正在关闭服务...');
// 关闭页面池
if (pagePool) {
console.log(' 关闭页面池...');
await pagePool.close();
}
// 关闭浏览器
if (browser) {
console.log(' 关闭浏览器...');
await browser.close();
}
console.log('✅ 服务已关闭');
process.exit(0);
}
process.on('SIGTERM', gracefulShutdown);
process.on('SIGINT', gracefulShutdown);