Skip to content

Instantly share code, notes, and snippets.

@Misaka-0x447f
Last active May 16, 2025 20:06
Show Gist options
  • Save Misaka-0x447f/105cd9a0736a1a62cd76b82d11e2f865 to your computer and use it in GitHub Desktop.
Save Misaka-0x447f/105cd9a0736a1a62cd76b82d11e2f865 to your computer and use it in GitHub Desktop.
deep unzip files and remove unintended duplicate folder name; 递归深度解压文件,并且合并重复的目录名,非常适用于百度网盘下载后的资源之类的,注意自己改 7z 位置;会往 cwd 下放失败日志,运行一次放一次,注意单独开一个目录;只能处理目录不能处理单个文件,单个文件你用这个干嘛
const fs = require('fs');
const path = require('path');
const os = require('os');
const { execFile } = require('child_process');
const readline = require('readline');
const { log } = require('console');
const fsp = fs.promises;
const sevenZipPath = 'C:\\Program Files\\7-Zip\\7z.exe';
// remember to return
const anyKeyToExit = () => {
console.log('\n按任意键退出。');
process.stdin.setRawMode(true);
process.stdin.resume();
process.stdin.on('data', process.exit.bind(process, 0));
setInterval(() => {}, 1000);
}
function ask(question) {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
return new Promise(resolve => rl.question(question, answer => {
rl.close();
resolve(answer.trim());
}));
}
let inputDir, outputDir;
let totalSize = 0;
let processedSize = 0;
const errorLogPath = path.join(
__dirname,
`deepUnzipErrorLog-${new Date().toISOString().replace(/[:.]/g, '-')}.txt`
);
try {
fsp.appendFile(errorLogPath, '', { encoding: 'utf8' });
} catch (err) {
console.error(`无法写入错误日志文件: ${errorLogPath}. 请检查文件路径或权限。`);
anyKeyToExit();
return;
}
async function writeToErrorLog(content) {
try {
await fsp.appendFile(errorLogPath, `===== [${new Date().toString()}] =====\n${content}` + os.EOL, { encoding: 'utf8' });
} catch (err) {
console.error(`写入错误日志失败: ${err.message}`);
}
}
function formatSize(size) {
if (size > 1024 * 1024 * 1024) return (size / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
if (size > 1024 * 1024) return (size / (1024 * 1024)).toFixed(2) + ' MB';
if (size > 1024) return (size / 1024).toFixed(2) + ' KB';
return size + ' B';
}
async function calcTotalSize(dir) {
let size = 0;
const stat = await fsp.stat(dir);
if (stat.isDirectory()) {
const entries = await fsp.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
size += await calcTotalSize(path.join(dir, entry.name));
}
} else {
size += stat.size;
}
return size;
}
let lastProgressBar = '';
let startTime = Date.now(); // 新增:记录开始时间
function formatTime(seconds) {
if (seconds < 60) return `${Math.round(seconds)} 秒`;
if (seconds < 3600) return `${Math.floor(seconds / 60)} 分 ${Math.round(seconds % 60)} 秒`;
return `${Math.floor(seconds / 3600)} 小时 ${Math.floor((seconds % 3600) / 60)} 分`;
}
function renderProgressBar(force = false) {
const percent = totalSize === 0 ? 0 : processedSize / totalSize;
const barLen = 40;
const filled = Math.round(barLen * percent);
const bar = '█'.repeat(filled) + '-'.repeat(barLen - filled);
const elapsed = (Date.now() - startTime) / 1000;
let etaStr = '';
if (processedSize > 0 && percent > 0) {
const speed = processedSize / elapsed; // bytes per second
const remain = totalSize - processedSize;
const eta = remain / speed;
etaStr = `,预计剩余: ${formatTime(eta)}`;
}
const progressStr = `进度: [${bar}] ${(percent * 100).toFixed(1)}% (${formatSize(processedSize)}/${formatSize(totalSize)})${etaStr}`;
// 只有进度条内容变化时才重绘,减少闪烁
if (!force && progressStr === lastProgressBar) return;
lastProgressBar = progressStr;
// 清除当前行并输出进度条
readline.cursorTo(process.stdout, 0);
readline.clearLine(process.stdout, 0);
process.stdout.write(progressStr);
}
// 包装日志输出,自动把进度条抬上去再放回
function logWithProgressBar(fn, ...args) {
if (fn === console.error) {
writeToErrorLog(args.join(' '));
}
// 抬上去
process.stdout.write('\n');
readline.moveCursor(process.stdout, 0, -1);
readline.clearLine(process.stdout, 0);
fn(`[${new Date().toString().replace(/[:.]/g, '-')}]`, ...args);
// 放回
renderProgressBar(true);
}
(async () => {
inputDir = await ask('输入目录路径: ');
outputDir = await ask('输出目录路径: ');
try {
// 验证输入目录是否存在
const inputExists = await fsp.stat(inputDir).then(stat => stat.isDirectory()).catch(() => false);
if (!inputExists) {
logWithProgressBar(console.error, '输入目录不存在,注意去掉引号');
anyKeyToExit();
return;
}
// 验证输出目录是否可写
await fsp.mkdir(outputDir, { recursive: true });
try {
await fsp.access(outputDir, fs.constants.W_OK);
} catch {
throw new Error(`输出目录不可写: ${outputDir}`);
}
// 统计总大小
logWithProgressBar(console.log, '正在统计总文件大小...');
totalSize = await calcTotalSize(inputDir);
processedSize = 0;
startTime = Date.now(); // 新增:重置开始时间
renderProgressBar(true);
await processEntry(inputDir, '', outputDir);
renderProgressBar(true);
logWithProgressBar(console.log, '\n全部处理完成\n');
anyKeyToExit();
} catch (err) {
logWithProgressBar(console.error, '处理出错:', err);
}
})();
const archiveExts = ['.zip', '.7z', '.rar', '.tar', '.gz'];
function getTempDir() {
return fsp.mkdtemp(path.join(os.tmpdir(), 'unpack-'));
}
function isArchive(filePath) {
return archiveExts.includes(path.extname(filePath).toLowerCase());
}
async function processEntry(srcPath, relPath, destRoot, retryCount = 0) {
try {
const stat = await fsp.stat(srcPath);
if (stat.isDirectory()) {
const entries = await fsp.readdir(srcPath, { withFileTypes: true });
for (const entry of entries) {
await processEntry(
path.join(srcPath, entry.name),
path.join(relPath, entry.name),
destRoot
);
}
} else if (isArchive(srcPath)) {
const tempDir = await getTempDir();
await new Promise((resolve, reject) => {
const child = execFile(
sevenZipPath,
['x', srcPath, `-o${tempDir}`, '-y'],
(error, stdout, stderr) => {
if (error || stderr) {
logWithProgressBar(console.error, `解压失败: ${srcPath} -> ${tempDir}`);
fsp.rm(tempDir, { recursive: true, force: true }).catch((cleanupErr) => {
logWithProgressBar(console.error, `清理临时目录失败: ${tempDir} -> ${cleanupErr.message}`);
});
reject(error);
return;
}
logWithProgressBar(console.log, `解压成功: ${srcPath} -> ${tempDir}`);
resolve();
}
);
// 实时输出 7z 的 stdout
// child.stdout.on('data', (data) => {
// logWithProgressBar(console.log, `[7z] ${data.toString().trim()}`);
// });
child.stderr.on('data', (data) => {
logWithProgressBar(console.error, `[7z错误] ${data.toString().trim()}`);
});
});
const relNoExt = relPath.replace(path.extname(relPath), '');
await processEntry(tempDir, relNoExt, destRoot);
try {
await fsp.rm(tempDir, { recursive: true, force: true });
} catch (cleanupErr) {
logWithProgressBar(console.error, `清理临时目录失败: ${tempDir} -> ${cleanupErr.message}`);
throw cleanupErr;
}
} else {
function mergeDuplicateFoldersAndFilename(relPath) {
const parts = relPath.split(path.sep).filter(Boolean);
if (parts.length === 0) return relPath;
const last = parts[parts.length - 1];
const lastNoExt = last.replace(path.extname(last), '');
let result = [];
for (let i = 0; i < parts.length - 1; i++) {
if (i === 0 || parts[i] !== parts[i - 1]) {
result.push(parts[i]);
}
}
if (
result.length > 0 &&
result[result.length - 1] === lastNoExt
) {
result.pop();
}
result.push(last);
return result.join(path.sep);
}
const mergedRelPath = mergeDuplicateFoldersAndFilename(relPath);
const destPath = path.join(destRoot, mergedRelPath);
await fsp.mkdir(path.dirname(destPath), { recursive: true });
logWithProgressBar(console.log, `正在复制: ${srcPath} -> ${destPath}`);
await fsp.copyFile(srcPath, destPath);
// 更新进度
processedSize += stat.size;
renderProgressBar();
}
} catch (err) {
if (retryCount < 5) {
logWithProgressBar(console.error, `处理 ${srcPath} 出错,重试 ${retryCount + 1}: ${err.message}`);
await new Promise((res) => setTimeout(res, 5000));
await processEntry(srcPath, relPath, destRoot, retryCount + 1);
} else {
logWithProgressBar(console.error, `处理 ${srcPath} 多次失败,跳过: ${err.message}`);
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment