Merge pull request #5 from Johnserf-Seed/fix/concurrent-decrypt-and-page-recovery

fix: 修复页面关闭错误并添加并发解密支持
This commit is contained in:
Evil0ctal authored and GitHub committed 2026-01-23 16:49:31 -08:00
commit 9f0c8a5def
2 files changed
+526 -81

No files matched your search

+1
View File
@@ -16,6 +16,7 @@ services:
environment:
- NODE_ENV=production
- PORT=3000
- POOL_SIZE=3
restart: unless-stopped
+525 -81
View File
@@ -34,8 +34,321 @@ 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;
/**
* 页面池管理器 - 支持并发处理
*/
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)
)
]);
}
/**
* 初始化 Playwright 浏览器
*/
@@ -141,8 +454,10 @@ app.get('/api/info', (req, res) => {
github: 'https://github.com/Evil0ctal/WeChat-Channels-Video-File-Decryption',
endpoints: {
health: 'GET /health',
decrypt: 'POST /api/decrypt',
keystream: 'POST /api/keystream'
poolStatus: 'GET /api/pool-status',
keystream: 'POST /api/keystream',
decrypt: 'POST /api/decrypt (串行)',
decryptConcurrent: 'POST /api/decrypt-concurrent (并发)'
}
});
});
@@ -166,43 +481,46 @@ app.post('/api/keystream', async (req, res) => {
});
}
// 确保浏览器已初始化
if (!page) {
await initBrowser();
}
console.log(`🔑 生成密钥流: decode_key=${decode_key}, format=${format}`);
// 调用浏览器中的 RPC 方法
const startTime = Date.now();
const keystreamBase64 = await page.evaluate(async (key) => {
return await window.generateKeystream(key);
}, decode_key);
// 使用锁机制确保串行处理
const result = await withPageLock(async () => {
const startTime = Date.now();
const duration = Date.now() - startTime;
// 调用浏览器中的 RPC 方法(带超时保护)
const keystreamBase64 = await evaluateWithTimeout(
async (key) => await window.generateKeystream(key),
decode_key,
30000
);
// 格式转换
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;
}
const duration = Date.now() - startTime;
console.log(`✅ 密钥流生成成功,耗时 ${duration}ms`);
// 格式转换
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;
}
res.json({
decode_key,
keystream,
format,
size: 131072,
duration_ms: duration,
timestamp: new Date().toISOString()
console.log(`✅ 密钥流生成成功,耗时 ${duration}ms`);
return {
decode_key,
keystream,
format,
size: 131072,
duration_ms: duration,
timestamp: new Date().toISOString()
};
});
res.json(result);
} catch (error) {
console.error('❌ 密钥流生成失败:', error.message);
res.status(500).json({ error: error.message });
@@ -226,57 +544,62 @@ app.post('/api/decrypt', upload.single('video'), async (req, res) => {
return res.status(400).json({ error: '缺少视频文件' });
}
// 确保浏览器已初始化
if (!page) {
await initBrowser();
}
console.log(`📹 解密请求:`);
console.log(` decode_key: ${decode_key}`);
console.log(` 文件: ${videoFile.originalname} (${(videoFile.size / 1024 / 1024).toFixed(2)} MB)`);
const startTime = Date.now();
// 根据文件大小动态设置超时时间(基础30秒 + 每MB额外1秒)
const fileSizeMB = videoFile.size / 1024 / 1024;
const timeout = Math.max(60000, 30000 + fileSizeMB * 1000);
// 步骤 1: 生成密钥流
console.log(' [1/3] 生成密钥流...');
const keystreamBase64 = await page.evaluate(async (key) => {
return await window.generateKeystream(key);
}, decode_key);
// 使用锁机制确保串行处理
const result = await withPageLock(async () => {
const startTime = Date.now();
// 步骤 2: 转换视频为 Base64
console.log(' [2/3] 编码视频数据...');
const encryptedBase64 = videoFile.buffer.toString('base64');
// 步骤 1: 生成密钥流
console.log(' [1/3] 生成密钥流...');
const keystreamBase64 = await evaluateWithTimeout(
async (key) => await window.generateKeystream(key),
decode_key,
30000
);
// 步骤 3: 在浏览器中执行解密
console.log(' [3/3] 执行 XOR 解密...');
const decryptedBase64 = await page.evaluate(
(params) => {
return window.decryptVideo(params.encrypted, params.keystream);
},
{ encrypted: encryptedBase64, keystream: keystreamBase64 }
);
// 步骤 2: 转换视频为 Base64
console.log(' [2/3] 编码视频数据...');
const encryptedBase64 = videoFile.buffer.toString('base64');
// Base64 → Buffer
const decrypted = Buffer.from(decryptedBase64, 'base64');
// 步骤 3: 在浏览器中执行解密(大文件需要更长超时)
console.log(' [3/3] 执行 XOR 解密...');
const decryptedBase64 = await evaluateWithTimeout(
(params) => window.decryptVideo(params.encrypted, params.keystream),
{ encrypted: encryptedBase64, keystream: keystreamBase64 },
timeout
);
// 验证 MP4 签名
const ftyp = decrypted.toString('utf8', 4, 8);
if (ftyp !== 'ftyp') {
throw new Error('解密失败:未找到 MP4 ftyp 签名,请检查 decode_key');
}
// Base64 → Buffer
const decrypted = Buffer.from(decryptedBase64, 'base64');
const duration = Date.now() - startTime;
console.log(`✅ 解密成功,耗时 ${duration}ms`);
// 验证 MP4 签名
const ftyp = decrypted.toString('utf8', 4, 8);
if (ftyp !== 'ftyp') {
throw new Error('解密失败:未找到 MP4 ftyp 签名,请检查 decode_key');
}
const duration = Date.now() - startTime;
console.log(`✅ 解密成功,耗时 ${duration}ms`);
return { decrypted, duration };
});
// 返回解密后的视频
res.set({
'Content-Type': 'video/mp4',
'Content-Length': decrypted.length,
'Content-Length': result.decrypted.length,
'Content-Disposition': `attachment; filename="decrypted_${Date.now()}.mp4"`,
'X-Decrypt-Duration': duration
'X-Decrypt-Duration': result.duration
});
res.send(decrypted);
res.send(result.decrypted);
} catch (error) {
console.error('❌ 解密失败:', error.message);
@@ -284,6 +607,112 @@ app.post('/api/decrypt', upload.single('video'), async (req, res) => {
}
});
/**
* 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 fileSizeMB = videoFile.size / 1024 / 1024;
const timeout = Math.max(60000, 30000 + fileSizeMB * 1000);
// 使用页面池执行(支持并发)
const result = await withPoolPage(async (pg) => {
const startTime = Date.now();
// 步骤 1: 生成密钥流
console.log(` [页面#${pg._poolIndex}] [1/3] 生成密钥流...`);
const keystreamBase64 = await evaluateWithTimeoutOnPage(
pg,
async (key) => await window.generateKeystream(key),
decode_key,
30000
);
// 步骤 2: 转换视频为 Base64
console.log(` [页面#${pg._poolIndex}] [2/3] 编码视频数据...`);
const encryptedBase64 = videoFile.buffer.toString('base64');
// 步骤 3: 在浏览器中执行解密
console.log(` [页面#${pg._poolIndex}] [3/3] 执行 XOR 解密...`);
const decryptedBase64 = await evaluateWithTimeoutOnPage(
pg,
(params) => window.decryptVideo(params.encrypted, params.keystream),
{ encrypted: encryptedBase64, keystream: keystreamBase64 },
timeout
);
// Base64 → Buffer
const decrypted = Buffer.from(decryptedBase64, 'base64');
// 验证 MP4 签名
const ftyp = decrypted.toString('utf8', 4, 8);
if (ftyp !== 'ftyp') {
throw new Error('解密失败:未找到 MP4 ftyp 签名,请检查 decode_key');
}
const duration = Date.now() - startTime;
console.log(`✅ [页面#${pg._poolIndex}] 解密成功,耗时 ${duration}ms`);
return { decrypted, duration };
});
// 返回解密后的视频
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 处理
*/
@@ -294,8 +723,10 @@ app.use((req, res) => {
available: [
'GET /',
'GET /health',
'GET /api/pool-status',
'POST /api/keystream',
'POST /api/decrypt'
'POST /api/decrypt',
'POST /api/decrypt-concurrent'
]
});
});
@@ -333,16 +764,23 @@ app.use((err, req, res, next) => {
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(' POST /api/keystream 生成密钥流');
console.log(' POST /api/decrypt 解密视频');
console.log('\n🎭 使用 Playwright 浏览器执行 WASM');
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);
@@ -357,18 +795,24 @@ app.use((err, req, res, next) => {
})();
// 优雅关闭
process.on('SIGTERM', async () => {
async function gracefulShutdown() {
console.log('\n👋 正在关闭服务...');
if (browser) {
await browser.close();
}
process.exit(0);
});
process.on('SIGINT', async () => {
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);