Last active
May 16, 2025 20:06
-
-
Save Misaka-0x447f/105cd9a0736a1a62cd76b82d11e2f865 to your computer and use it in GitHub Desktop.
deep unzip files and remove unintended duplicate folder name; 递归深度解压文件,并且合并重复的目录名,非常适用于百度网盘下载后的资源之类的,注意自己改 7z 位置;会往 cwd 下放失败日志,运行一次放一次,注意单独开一个目录;只能处理目录不能处理单个文件,单个文件你用这个干嘛
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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