Categories
Scripts

사진 폴더 중복 제거 자동화 스크립트(Node.js)

사용예시

  1. 사진이 한 곳에 모아진 폴더가 있음
  2. 다른 폴더에도 사진이 있는데 중복인지 확인/처리하고 싶을 때

폴더와 폴더를 비교하여 중복 파일을 제거하는 느낌보다는, 이미 취합된 사진 폴더를 중심으로 여타 폴더에 중복된 사진들이 있는지 검사 및 처리하는 용도에 가깝습니다.

백업이 여러 버전이 있을 때 병합하는 용도로 돌림. 스크립트 돌리면 달아서 Hash 값 비교해서 처리합니다. 스크립트 사용 출력 예시는 다음과 같습니다

[BASE] 인덱스 저장: ...cache.json (해시 1543, 캐시재사용 0, 실패 0)
[DEL] D:\photos\archive\IMG_0001.JPG          ← 실제 삭제
[DRY] DELETE D:\photos\archive\IMG_0001.JPG   ← dry-run

[결과]
  스캔된 target 파일 : 842
  해시 검증한 파일   : 831 (캐시 재사용 11)
  삭제됨             : 217
  유지(고유)         : 614

Node.js v22 기반

/**
 * dedupe_against_base.js
 *
 * base 폴더의 파일 목록(경로/수정일/생성일/크기/md5)을 JSON 캐시로 인덱싱한 뒤,
 * target 폴더 내 파일을 재귀 탐색하여 base 와 MD5 가 일치하는 파일을 삭제합니다.
 *
 * 동작:
 *   1) base 폴더를 재귀 탐색하여 모든 파일의 메타 + md5 를 계산하고
 *      JSON 캐시 파일(--cache)에 저장합니다.
 *   2) 재실행 시 캐시가 존재하면 (path + size + mtimeMs) 가 일치하는 항목은
 *      해시 재계산을 건너뜁니다. 누락된 항목만 추가 계산합니다.
 *   3) target 폴더를 재귀 탐색하면서 각 파일의 md5 를 계산하고,
 *      base 의 md5 집합에 존재하면 해당 target 파일을 삭제합니다.
 *
 * 사용법:
 *   node scripts/dedupe_against_base.js --base <baseDir> --target <targetDir> [옵션]
 *
 * 옵션:
 *   --base <경로>         기준 폴더 (필수)
 *   --target <경로>       중복 제거 대상 폴더 (필수)
 *   --cache <경로>        base 인덱스 JSON 경로
 *                         (기본: ./cached/<base 경로>/cache.json)
 *   --target-cache <경로> target 인덱스 JSON 경로
 *                         (기본: ./cached/<target 경로>/cache.json)
 *   --dry-run             실제 삭제 없이 시뮬레이션만 수행
 *   --no-cache            base/target 캐시 사용 안 함 (모두 재해싱)
 *   --rebuild-cache       기존 캐시를 무시하고 새로 생성 (base/target 모두)
 *   --log <경로>          삭제된 파일 목록을 JSON 으로 기록
 *   --concurrency <N>     해시 동시 처리 개수 (기본: 8)
 *
 * 예:
 *   node scripts/dedupe_against_base.js --base ./merged --target "D:\photos\archive"
 *   node scripts/dedupe_against_base.js --base ./merged --target ./tmp --dry-run
 */

'use strict';

const fs     = require('fs');
const fsp    = require('fs/promises');
const path   = require('path');
const crypto = require('crypto');

// ── Argument parsing ───────────────────────────────────────────────────────────

function parseArgs(argv) {
  const opts = {
    base: null,
    target: null,
    cache: null,
    targetCache: null,
    dryRun: false,
    noCache: false,
    rebuildCache: false,
    log: null,
    concurrency: 8,
  };
  for (let i = 0; i < argv.length; i++) {
    const a = argv[i];
    switch (a) {
      case '--base':           opts.base = argv[++i]; break;
      case '--target':         opts.target = argv[++i]; break;
      case '--cache':          opts.cache = argv[++i]; break;
      case '--target-cache':   opts.targetCache = argv[++i]; break;
      case '--dry-run':        opts.dryRun = true; break;
      case '--no-cache':       opts.noCache = true; break;
      case '--rebuild-cache':  opts.rebuildCache = true; break;
      case '--log':            opts.log = argv[++i]; break;
      case '--concurrency':    opts.concurrency = parseInt(argv[++i], 10) || 8; break;
      case '-h':
      case '--help':
        printHelpAndExit(0);
        break;
      default:
        console.error(`알 수 없는 인자: ${a}`);
        printHelpAndExit(1);
    }
  }
  if (!opts.base) {
    console.error('--base 는 필수입니다.');
    printHelpAndExit(1);
  }
  opts.base = path.resolve(opts.base);
  if (opts.target) opts.target = path.resolve(opts.target);
  opts.cache = opts.cache
    ? path.resolve(opts.cache)
    : defaultCachePathFor(opts.base);
  if (opts.target && !opts.targetCache) {
    opts.targetCache = defaultCachePathFor(opts.target);
  } else if (opts.targetCache) {
    opts.targetCache = path.resolve(opts.targetCache);
  }
  if (opts.log) opts.log = path.resolve(opts.log);
  return opts;
}

function defaultCachePathFor(absPath) {
  const parsed = path.parse(absPath);
  let driveSeg = parsed.root.replace(/[:\\/]/g, '');
  if (!driveSeg) driveSeg = '__unc__';
  const rest = absPath.slice(parsed.root.length);
  return path.join(__dirname, 'cached', driveSeg.toLowerCase(), rest, 'cache.json');
}

function printHelpAndExit(code) {
  const help = `\n사용법:\n  node scripts/dedupe_against_base.js --base <baseDir> --target <targetDir> [옵션]\n\n옵션:\n  --base <경로>         기준 폴더 (필수)\n  --target <경로>       중복 제거 대상 폴더 (필수)\n  --cache <경로>        base 인덱스 JSON 경로 (기본: <base>/_dedupe_cache.json)\n  --dry-run             실제 삭제 없이 시뮬레이션만 수행\n  --no-cache            캐시 사용 안 함\n  --rebuild-cache       기존 캐시를 무시하고 새로 생성\n  --log <경로>          삭제된 파일 목록 JSON 기록\n  --concurrency <N>     해시 동시 처리 개수 (기본: 8)\n`;
  console.log(help);
  process.exit(code);
}

// ── Utils ───────────────────────────────────────────────────────────────────────

async function* walk(dir) {
  let entries;
  try {
    entries = await fsp.readdir(dir, { withFileTypes: true });
  } catch (err) {
    console.warn(`[WARN] 디렉터리 읽기 실패: ${dir} (${err.message})`);
    return;
  }
  for (const entry of entries) {
    const full = path.join(dir, entry.name);
    if (entry.isDirectory()) {
      yield* walk(full);
    } else if (entry.isFile()) {
      yield full;
    }
  }
}

function md5OfFile(filePath) {
  return new Promise((resolve, reject) => {
    const hash = crypto.createHash('md5');
    const stream = fs.createReadStream(filePath);
    stream.on('error', reject);
    stream.on('data', chunk => hash.update(chunk));
    stream.on('end', () => resolve(hash.digest('hex')));
  });
}

async function mapWithConcurrency(items, limit, worker) {
  const results = new Array(items.length);
  let idx = 0;
  const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
    while (true) {
      const i = idx++;
      if (i >= items.length) return;
      results[i] = await worker(items[i], i);
    }
  });
  await Promise.all(runners);
  return results;
}

function loadCache(cachePath) {
  if (!fs.existsSync(cachePath)) return null;
  try {
    const raw = fs.readFileSync(cachePath, 'utf8');
    return JSON.parse(raw);
  } catch (err) {
    console.warn(`[WARN] 캐시 로드 실패 (${cachePath}): ${err.message}`);
    return null;
  }
}

function saveCache(cachePath, data) {
  fs.mkdirSync(path.dirname(cachePath), { recursive: true });
  const tmp = cachePath + '.tmp';
  fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
  fs.renameSync(tmp, cachePath);
}

// ── Base indexing ──────────────────────────────────────────────────────────────

async function buildBaseIndex(opts) {
  const { base, cache, noCache, rebuildCache, concurrency } = opts;

  if (!fs.existsSync(base) || !fs.statSync(base).isDirectory()) {
    throw new Error(`base 폴더가 존재하지 않습니다: ${base}`);
  }

  const prev = (noCache || rebuildCache) ? null : loadCache(cache);
  const prevByPath = new Map();
  if (prev && Array.isArray(prev.files)) {
    for (const f of prev.files) prevByPath.set(f.path, f);
  }

  console.log(`[BASE] 스캔: ${base}`);
  const files = [];
  for await (const fp of walk(base)) {
    if (path.resolve(fp) === path.resolve(cache)) continue;
    files.push(fp);
  }
  console.log(`[BASE] 파일 ${files.length}개 발견`);

  let hashed = 0, cached = 0, failed = 0;
  const records = await mapWithConcurrency(files, concurrency, async (fp) => {
    let stat;
    try {
      stat = await fsp.stat(fp);
    } catch (err) {
      failed++;
      console.warn(`[WARN] stat 실패: ${fp} (${err.message})`);
      return null;
    }
    const prevRec = prevByPath.get(fp);
    if (prevRec
        && prevRec.size === stat.size
        && prevRec.mtimeMs === stat.mtimeMs
        && prevRec.md5) {
      cached++;
      return prevRec;
    }
    let md5;
    try {
      md5 = await md5OfFile(fp);
    } catch (err) {
      failed++;
      console.warn(`[WARN] 해시 실패: ${fp} (${err.message})`);
      return null;
    }
    hashed++;
    if ((hashed + cached) % 200 === 0) {
      console.log(`[BASE] 진행 ${hashed + cached}/${files.length} (해시 ${hashed}, 캐시 ${cached})`);
    }
    return {
      path: fp,
      size: stat.size,
      mtimeMs: stat.mtimeMs,
      ctimeMs: stat.ctimeMs,
      birthtimeMs: stat.birthtimeMs,
      mtime: new Date(stat.mtimeMs).toISOString(),
      ctime: new Date(stat.ctimeMs).toISOString(),
      birthtime: new Date(stat.birthtimeMs).toISOString(),
      md5,
    };
  });

  const valid = records.filter(Boolean);
  const index = {
    base,
    generatedAt: new Date().toISOString(),
    count: valid.length,
    files: valid,
  };

  saveCache(cache, index);
  console.log(`[BASE] 인덱스 저장: ${cache} (해시 ${hashed}, 캐시재사용 ${cached}, 실패 ${failed})`);

  const md5Set = new Set();
  const md5ToPaths = new Map();
  for (const r of valid) {
    md5Set.add(r.md5);
    if (!md5ToPaths.has(r.md5)) md5ToPaths.set(r.md5, []);
    md5ToPaths.get(r.md5).push(r.path);
  }
  return { md5Set, md5ToPaths };
}

// ── Target deduplication ───────────────────────────────────────────────────────

async function dedupeTarget(opts, baseIdx) {
  const { target, dryRun, concurrency, base, targetCache, noCache, rebuildCache } = opts;
  const { md5Set, md5ToPaths } = baseIdx;

  if (!fs.existsSync(target) || !fs.statSync(target).isDirectory()) {
    throw new Error(`target 폴더가 존재하지 않습니다: ${target}`);
  }

  const prev = (noCache || rebuildCache) ? null : loadCache(targetCache);
  const prevByPath = new Map();
  if (prev && Array.isArray(prev.files)) {
    for (const f of prev.files) prevByPath.set(f.path, f);
  }

  console.log(`[TARGET] 스캔: ${target}`);
  const files = [];
  for await (const fp of walk(target)) {
    if (path.resolve(fp) === path.resolve(targetCache)) continue;
    files.push(fp);
  }
  console.log(`[TARGET] 파일 ${files.length}개 발견`);

  const deletions = [];
  const survivors = [];
  let kept = 0, checked = 0, cachedHits = 0, failed = 0;

  await mapWithConcurrency(files, concurrency, async (fp) => {
    const rel = path.relative(base, fp);
    const insideBase = rel && !rel.startsWith('..') && !path.isAbsolute(rel);
    if (insideBase) {
      kept++;
      return;
    }

    let stat;
    try {
      stat = await fsp.stat(fp);
    } catch (err) {
      failed++;
      console.warn(`[WARN] stat 실패: ${fp} (${err.message})`);
      return;
    }

    let md5;
    const prevRec = prevByPath.get(fp);
    if (prevRec
        && prevRec.size === stat.size
        && prevRec.mtimeMs === stat.mtimeMs
        && prevRec.md5) {
      md5 = prevRec.md5;
      cachedHits++;
    } else {
      try {
        md5 = await md5OfFile(fp);
      } catch (err) {
        failed++;
        console.warn(`[WARN] 해시 실패: ${fp} (${err.message})`);
        return;
      }
    }
    checked++;
    if (checked % 200 === 0) {
      console.log(`[TARGET] 진행 ${checked}/${files.length} (해시 ${checked - cachedHits}, 캐시 ${cachedHits}, 삭제 ${deletions.length})`);
    }

    const record = {
      path: fp,
      size: stat.size,
      mtimeMs: stat.mtimeMs,
      ctimeMs: stat.ctimeMs,
      birthtimeMs: stat.birthtimeMs,
      mtime: new Date(stat.mtimeMs).toISOString(),
      ctime: new Date(stat.ctimeMs).toISOString(),
      birthtime: new Date(stat.birthtimeMs).toISOString(),
      md5,
    };

    if (!md5Set.has(md5)) {
      kept++;
      survivors.push(record);
      return;
    }
    const matches = md5ToPaths.get(md5) || [];
    if (dryRun) {
      console.log(`[DRY] DELETE ${fp}  (== ${matches[0]})`);
      deletions.push({ path: fp, md5, matchedBase: matches });
      survivors.push(record);
      return;
    }
    try {
      await fsp.unlink(fp);
      console.log(`[DEL] ${fp}`);
      deletions.push({ path: fp, md5, matchedBase: matches });
    } catch (err) {
      failed++;
      console.warn(`[WARN] 삭제 실패: ${fp} (${err.message})`);
      survivors.push(record);
    }
  });

  try {
    saveCache(targetCache, {
      target,
      generatedAt: new Date().toISOString(),
      count: survivors.length,
      files: survivors,
    });
    console.log(`[TARGET] 캐시 저장: ${targetCache}`);
  } catch (err) {
    console.warn(`[WARN] target 캐시 저장 실패: ${err.message}`);
  }

  console.log(`\n[결과]`);
  console.log(`  스캔된 target 파일 : ${files.length}`);
  console.log(`  해시 검증한 파일   : ${checked} (캐시 재사용 ${cachedHits})`);
  console.log(`  ${dryRun ? '삭제 예정' : '삭제됨'}        : ${deletions.length}`);
  console.log(`  유지(고유)         : ${kept}`);
  console.log(`  실패               : ${failed}`);

  if (opts.log) {
    fs.mkdirSync(path.dirname(opts.log), { recursive: true });
    fs.writeFileSync(opts.log, JSON.stringify({
      base, target, dryRun,
      generatedAt: new Date().toISOString(),
      deletions,
    }, null, 2));
    console.log(`  로그 저장          : ${opts.log}`);
  }
}

// ── Main ───────────────────────────────────────────────────────────────────────

(async () => {
  const opts = parseArgs(process.argv.slice(2));
  console.log('옵션:', {
    base: opts.base,
    target: opts.target || '(standby — stdin 에서 입력대기)',
    cache: opts.cache,
    targetCache: opts.targetCache || '(target 입력 시 자동 결정)',
    dryRun: opts.dryRun,
    noCache: opts.noCache,
    rebuildCache: opts.rebuildCache,
    concurrency: opts.concurrency,
  });

  try {
    const baseIdx = await buildBaseIndex(opts);

    if (opts.target) {
      await dedupeTarget(opts, baseIdx);
      return;
    }

    await runInteractive(opts, baseIdx);
  } catch (err) {
    console.error(`[ERROR] ${err.message}`);
    process.exit(1);
  }
})();

async function runInteractive(opts, baseIdx) {
  const readline = require('readline');
  const rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout,
    terminal: process.stdin.isTTY === true,
  });

  console.log('\n=== standby 모드 ===');
  console.log('target 절대경로를 한 줄씩 입력하세요. (exit / quit / Ctrl+C 로 종료)');
  console.log('명령: "!dry on", "!dry off", "!rebuild on", "!rebuild off", "!status"');

  const prompt = () => {
    process.stdout.write(`\n[base=${opts.base}] ${opts.dryRun ? '(DRY) ' : ''}target> `);
  };

  let busy = false;
  const queue = [];

  const processNext = async () => {
    if (busy) return;
    const line = queue.shift();
    if (line === undefined) { prompt(); return; }
    busy = true;
    try {
      await handleLine(line);
    } catch (err) {
      console.error(`[ERROR] ${err.message}`);
    } finally {
      busy = false;
      processNext();
    }
  };

  const handleLine = async (raw) => {
    let input = raw.trim();
    if ((input.startsWith('"') && input.endsWith('"'))
        || (input.startsWith("'") && input.endsWith("'"))) {
      input = input.slice(1, -1);
    }
    if (!input) return;

    if (input === 'exit' || input === 'quit') {
      rl.close();
      return;
    }
    if (input === '!status') {
      console.log(`base=${opts.base}\ndryRun=${opts.dryRun}\nrebuildCache=${opts.rebuildCache}\nnoCache=${opts.noCache}`);
      return;
    }
    if (input.startsWith('!dry ')) {
      opts.dryRun = input.endsWith('on');
      console.log(`dryRun = ${opts.dryRun}`);
      return;
    }
    if (input.startsWith('!rebuild ')) {
      opts.rebuildCache = input.endsWith('on');
      console.log(`rebuildCache = ${opts.rebuildCache}`);
      return;
    }

    const targetPath = path.resolve(input);
    if (!fs.existsSync(targetPath) || !fs.statSync(targetPath).isDirectory()) {
      console.error(`[SKIP] 폴더가 아니거나 존재하지 않음: ${targetPath}`);
      return;
    }

    const jobOpts = Object.assign({}, opts, {
      target: targetPath,
      targetCache: defaultCachePathFor(targetPath),
    });

    const t0 = Date.now();
    await dedupeTarget(jobOpts, baseIdx);
    console.log(`[DONE] ${targetPath} (${((Date.now() - t0) / 1000).toFixed(1)}s)`);
  };

  rl.on('line', (line) => {
    queue.push(line);
    processNext();
  });

  rl.on('close', () => {
    console.log('\nstandby 종료.');
    process.exit(0);
  });

  prompt();
}

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.