Всем привет, Хабравчане!

Наверное, с беспорядком из файлов на ПК знаком практически каждый: кто‑то сам годами не следит так как лень, а кого‑то периодически просят помочь почистить или разобрать чужой компьютер — «ну ты же айтишник, ты всё можешь!». Да, и сервер настроить, и кофемашину починить! Всегда пожалуйста, милости просим:)

Я не исключение. Вообще, «айтишник» в XXI веке звучит гордо, почти как «SuperMAN». Кто бы мог подумать ещё лет двадцать назад, что любой мало‑мальский инженер или верстальщик, да уже и менеджер, работающий с кодом или железом, станет носить это всемогущее звание!

Ближе к делу

За несколько лет на нерабочем Mac у меня накопилось около 860 ГБ файлов: фотографии и видео с разных телефонов, документы, PSD, записи экрана, старые проекты и даже резервные копии.

Разбирать такой архив вручную оказалось бессмысленно, а давать внешним сервисам доступ к моему компьютеру я не хотел. При этом один и тот же файл мог лежать в пяти разных папках и называться совершенно по‑разному.

Поэтому для разбора этого добра я написал небольшой Python‑скрипт с помощью ИИ + довёл руками до рабочей истории.

Суть скрипта: сначала он ищет точные дубли, а затем готовит оставшиеся файлы к сортировке и, если всё ок, сортирует. Если нет — можно откатить.

Для поиска точных дублей в скрипте используется SHA-256 (цифровой отпечаток файла).

Имя при этом вообще не важно: IMG_4382.jpg и photo_2022.jpg могут называться как угодно и лежать в разных папках, но если их содержимое полностью совпадает, SHA-256 будет одинаковым. Достаточно изменить в файле хотя бы один байт и хеш практически наверняка станет другим.

Поэтому в скрипте связка одинаковый размер + одинаковый SHA-256 используется как проверка на точную копию. Сам SHA-256 ничего не шифрует и не хранит содержимое файла, он только рассчитывает его контрольный отпечаток.

Как запустить: 6 шагов

Шаг 1. Открыть Terminal

Проверяем, что установлен Python 3 (вставляем команду которая ниже):

python3 --version

Шаг 2. Вставить готовый код

Копируем весь скрипт из статьи ниже:

Скрытый текст
TMP_SCRIPT="$(mktemp /tmp/mac_file_organizer.XXXXXX)"

cat > "$TMP_SCRIPT" <<'PY'
from __future__ import annotations
import csv, datetime as dt, hashlib, json, os, re, shutil, subprocess, sys, unicodedata, zipfile
from collections import Counter, defaultdict
from pathlib import Path

PHOTO={".jpg",".jpeg",".png",".heic",".heif",".webp",".gif",".tif",".tiff",".bmp",".dng",".raw",".cr2",".cr3",".nef",".arw"}
VIDEO={".mov",".mp4",".m4v",".avi",".mkv",".webm",".mts",".m2ts",".3gp"}
AUDIO={".mp3",".m4a",".aac",".wav",".flac",".ogg",".aiff"}
DOCS={".pdf",".doc",".docx",".rtf",".txt",".odt",".pages"}
SHEETS={".xls",".xlsx",".xlsm",".csv",".tsv",".ods",".numbers"}
PRESENTATIONS={".ppt",".pptx",".key",".odp"}
DESIGN={".psd",".ai",".eps",".svg",".sketch",".fig"}
ARCHIVES={".zip",".rar",".7z",".tar",".gz"}
CODE={".py",".js",".ts",".tsx",".jsx",".php",".html",".htm",".css",".sql",".json",".xml",".yaml",".yml",".sh",".command"}
MEDIA=PHOTO|VIDEO|AUDIO
WORK=DOCS|SHEETS|PRESENTATIONS|DESIGN|ARCHIVES

SKIP_NAMES={".git","node_modules",".Trash","__pycache__","_РАЗОБРАНО"}
SKIP_PREFIXES=("_FILE_AUDIT_","_FILE_QUARANTINE_","_ORGANIZER_PLAN_","_ORGANIZER_RUN_")
PROJECT_MARKERS={".git","package.json","pyproject.toml","requirements.txt","Cargo.toml","go.mod","composer.json"}
GENERIC={"разное","старое","копия","фото с телефона","новая папка","backup","old","temp","photos","pictures","documents","downloads"}
PRIVATE={"паспорт","права","свидетельство","личное"}
MONTHS={"январ":1,"феврал":2,"март":3,"апрел":4,"май":5,"мая":5,"июн":6,"июл":7,"август":8,"сентябр":9,"октябр":10,"ноябр":11,"декабр":12}

def stamp(): return dt.datetime.now().strftime("%Y%m%d_%H%M%S")
def norm(s): return unicodedata.normalize("NFC",str(s))
def clean(s):
    s=re.sub(r"\s+"," ",norm(s).replace("/"," - ").replace(":"," - ")).strip(" ._-")
    return (s or "Без названия")[:120]

def human(n):
    x=float(n)
    for u in ("Б","КБ","МБ","ГБ","ТБ"):
        if x<1024 or u=="ТБ": return f"{int(x)} {u}" if u=="Б" else f"{x:.1f} {u}"
        x/=1024

def choose_folder():
    r=subprocess.run(["osascript","-e",'POSIX path of (choose folder with prompt "Выберите папку, которую нужно разобрать")'],
                     capture_output=True,text=True)
    if not r.stdout.strip(): print("Папка не выбрана."); raise SystemExit
    return Path(r.stdout.strip()).expanduser().resolve()

def is_project_dir(p):
    try: return bool({x.name for x in p.iterdir()} & PROJECT_MARKERS)
    except Exception: return False

def iter_files(root):
    for dp,dirs,files in os.walk(root):
        cur=Path(dp)
        if cur!=root and is_project_dir(cur):
            dirs[:]=[]
            continue
        dirs[:]=[d for d in dirs if d not in SKIP_NAMES and not any(d.startswith(p) for p in SKIP_PREFIXES)]
        for name in files:
            p=cur/name
            try:
                if not p.is_symlink(): yield p
            except Exception: pass

def sha256_file(p,block=1024*1024):
    h=hashlib.sha256()
    try:
        with p.open("rb") as f:
            while True:
                b=f.read(block)
                if not b: break
                h.update(b)
        return h.hexdigest()
    except Exception: return None

def category(p):
    e=p.suffix.lower()
    for exts,name in ((PHOTO,"Фото"),(VIDEO,"Видео"),(AUDIO,"Аудио"),(DOCS,"Документы"),
                      (SHEETS,"Таблицы"),(PRESENTATIONS,"Презентации"),(DESIGN,"Дизайн"),
                      (ARCHIVES,"Архивы"),(CODE,"Код")):
        if e in exts: return name
    return "Прочее"

def parse_date(text):
    s=norm(text).lower()
    patterns=[
        (r"(?<!\d)(20\d{2}|19\d{2})[-_. /](0?[1-9]|1[0-2])[-_. /](0?[1-9]|[12]\d|3[01])(?!\d)","ymd"),
        (r"(?<!\d)(20\d{2}|19\d{2})(0[1-9]|1[0-2])([0-2]\d|3[01])(?!\d)","ymd"),
        (r"(?<!\d)(0?[1-9]|[12]\d|3[01])[-_. /](0?[1-9]|1[0-2])[-_. /](20\d{2}|19\d{2})(?!\d)","dmy"),
    ]
    for rx,order in patterns:
        m=re.search(rx,s)
        if not m: continue
        a,b,c=map(int,m.groups())
        try: return (dt.datetime(a,b,c),100) if order=="ymd" else (dt.datetime(c,b,a),100)
        except Exception: pass
    for word,month in MONTHS.items():
        if word in s:
            y=re.search(r"(?<!\d)(20\d{2}|19\d{2})(?!\d)",s)
            if y: return dt.datetime(int(y.group(1)),month,1),85
    y=re.search(r"(?<!\d)(20\d{2}|19\d{2})(?!\d)",s)
    return (dt.datetime(int(y.group(1)),1,1),60) if y else (None,0)

def exif_dates(paths):
    exe=shutil.which("exiftool")
    if not exe: return {}
    out={}
    print("ExifTool найден — читаю даты фото и видео...")
    for start in range(0,len(paths),200):
        chunk=paths[start:start+200]
        try:
            r=subprocess.run([exe,"-j","-DateTimeOriginal","-MediaCreateDate","-CreateDate","-TrackCreateDate",*[str(p) for p in chunk]],
                             capture_output=True,text=True,timeout=120)
            for item in json.loads(r.stdout or "[]"):
                src=item.get("SourceFile")
                if not src: continue
                for key in ("DateTimeOriginal","MediaCreateDate","CreateDate","TrackCreateDate"):
                    m=re.match(r"(\d{4}):(\d{2}):(\d{2})",str(item.get(key,"")))
                    if m:
                        out[os.path.abspath(src)]=dt.datetime(*map(int,m.groups()))
                        break
        except Exception: pass
    return out

def spotlight_date(p):
    try:
        r=subprocess.run(["mdls","-raw","-name","kMDItemContentCreationDate",str(p)],capture_output=True,text=True,timeout=5)
        m=re.search(r"(20\d{2}|19\d{2})-(\d{2})-(\d{2})",r.stdout)
        return dt.datetime(*map(int,m.groups())) if m else None
    except Exception: return None

def filesystem_date(p):
    try:
        st=p.stat()
        return dt.datetime.fromtimestamp(getattr(st,"st_birthtime",None) or st.st_mtime)
    except Exception: return dt.datetime.now()

def infer_date(p,root,exif):
    ap=os.path.abspath(str(p))
    if ap in exif: return exif[ap],100,"EXIF / QuickTime"
    value,conf=parse_date(p.name)
    if value and conf>=85: return value,conf,"имя файла"
    best=(value,conf,"имя файла" if value else "")
    for part in reversed(p.relative_to(root).parts[:-1]):
        v,c=parse_date(part)
        if v and c>best[1]: best=(v,c,f"папка: {part}")
    if best[0] and best[1]>=85: return best
    if p.suffix.lower() in MEDIA:
        v=spotlight_date(p)
        if v: return v,75,"Spotlight"
    return best if best[0] else (filesystem_date(p),30,"дата файла macOS")

def extract_text(p,limit=50000):
    e=p.suffix.lower()
    try:
        if e in {".txt",".csv",".tsv",".html",".htm",".rtf"}:
            return p.read_text(encoding="utf-8",errors="ignore")[:limit]
        if e==".docx":
            with zipfile.ZipFile(p) as z: raw=z.read("word/document.xml")
            return re.sub(r"\s+"," ",re.sub(r"<[^>]+>"," ",raw.decode("utf-8","ignore")))[:limit]
        if e in {".xlsx",".xlsm"}:
            chunks=[]
            with zipfile.ZipFile(p) as z:
                for name in ("xl/sharedStrings.xml","xl/workbook.xml"):
                    if name in z.namelist():
                        chunks.append(re.sub(r"<[^>]+>"," ",z.read(name).decode("utf-8","ignore")))
            return re.sub(r"\s+"," "," ".join(chunks))[:limit]
        if e==".pdf":
            r=subprocess.run(["mdls","-raw","-name","kMDItemTextContent",str(p)],capture_output=True,text=True,timeout=5)
            return "" if r.stdout.strip()=="(null)" else r.stdout[:limit]
    except Exception: pass
    return ""

def document_type(ctx,e):
    ctx=ctx.lower()
    rules=[
        (("приложение","дополнительное соглашение","доп. соглашение"),"Приложения"),
        (("договор","contract"),"Договоры"),
        (("акт выполн","акт №"),"Акты"),
        (("счет на оплат","счёт на оплат","invoice"),"Счета"),
        (("коммерческое предложение","коммерческ","кп "),"Коммерческие предложения"),
        (("бриф","brief"),"Брифы"),
        (("техническое задание","тз "),"ТЗ"),
        (("отчет","отчёт","report"),"Отчёты"),
        (("реквизит",),"Реквизиты"),
        (("seo","семантик","метрика","директ","поисковые запрос"),"SEO и реклама"),
    ]
    for words,name in rules:
        if any(w in ctx for w in words): return name
    if e in SHEETS: return "Таблицы"
    if e in PRESENTATIONS: return "Презентации"
    if e in DESIGN: return "Дизайн"
    if e in ARCHIVES: return "Архивы"
    return "Документы"

def unique_path(dst,reserved=None):
    reserved=reserved if reserved is not None else set()
    if not dst.exists() and str(dst) not in reserved:
        reserved.add(str(dst)); return dst
    for i in range(2,10000):
        c=dst.with_name(f"{dst.stem}__{i}{dst.suffix}")
        if not c.exists() and str(c) not in reserved:
            reserved.add(str(c)); return c
    raise RuntimeError("Не удалось подобрать уникальное имя")

def write_csv(path,fields,rows):
    with path.open("w",encoding="utf-8-sig",newline="") as f:
        w=csv.DictWriter(f,fieldnames=fields); w.writeheader(); w.writerows(rows)

def audit(root):
    print(f"\nСканирую:\n{root}")
    files=list(iter_files(root)); cats=Counter(); sizes=defaultdict(list); total=0
    for i,p in enumerate(files,1):
        try: size=p.stat().st_size
        except Exception: size=0
        total+=size; cats[category(p)]+=1
        if size>0: sizes[size].append(p)
        if i%1000==0: print(f"Проверено {i}/{len(files)}")
    print("\nИщу точные дубли...")
    groups=[]
    for size,paths in sizes.items():
        if len(paths)<2: continue
        hashes=defaultdict(list)
        for p in paths:
            h=sha256_file(p)
            if h: hashes[h].append(p)
        groups += [{"hash":h,"size":size,"paths":ps} for h,ps in hashes.items() if len(ps)>1]
    dup_files=sum(len(g["paths"])-1 for g in groups)
    dup_bytes=sum(g["size"]*(len(g["paths"])-1) for g in groups)
    out=root/f"_FILE_AUDIT_{stamp()}"; out.mkdir()
    (out/"summary.json").write_text(json.dumps({
        "files":len(files),"size":human(total),"categories":dict(cats),
        "duplicate_groups":len(groups),"duplicate_files":dup_files,"duplicate_size":human(dup_bytes)
    },ensure_ascii=False,indent=2),encoding="utf-8")
    print("\n"+"="*48+"\nАУДИТ ГОТОВ\n"+"="*48)
    print(f"\nФайлов: {len(files)}\nРазмер:  {human(total)}")
    for name,count in cats.most_common(): print(f"{name:<14} {count}")
    print(f"\nГрупп дублей: {len(groups)}\nЛишних копий: {dup_files}\nОбъём дублей: {human(dup_bytes)}")
    return groups

def keeper_score(p,root):
    rel=p.relative_to(root); text=str(rel).lower()
    return len(rel.parts)*10 + (1000 if "разобран" in text else 0) - (500 if re.search(r"(копия|copy|\(\d+\))",p.name,re.I) else 0) + (100 if re.search(r"(19|20)\d{2}",text) else 0)

def quarantine(root,groups):
    n=sum(len(g["paths"])-1 for g in groups); b=sum(g["size"]*(len(g["paths"])-1) for g in groups)
    print(f"\nБудет перемещено: {n} файлов / {human(b)}\nФайлы НЕ будут удалены.")
    if input("\nВведите КАРАНТИН: ").strip()!="КАРАНТИН":
        print("Отменено."); return
    q=root/f"_FILE_QUARANTINE_{stamp()}"; q.mkdir()
    undo=[]; moved=skipped=0
    for g in groups:
        paths=sorted(g["paths"],key=lambda p:keeper_score(p,root),reverse=True)
        expected=g["hash"]; keeper=paths[0]
        if any((not p.exists()) or sha256_file(p)!=expected for p in paths):
            skipped+=len(paths)-1; continue
        for p in paths[1:]:
            dst=q/p.relative_to(root); dst.parent.mkdir(parents=True,exist_ok=True)
            try:
                shutil.move(str(p),str(dst))
                undo.append({"original":str(p),"quarantine":str(dst),"keeper":str(keeper),"sha256":expected}); moved+=1
            except Exception: skipped+=1
    write_csv(q/"undo.csv",["original","quarantine","keeper","sha256"],undo)
    print(f"\nПеремещено: {moved}\nПропущено: {skipped}\nКарантин:\n{q}")

def latest(root,prefix):
    xs=sorted((p for p in root.iterdir() if p.is_dir() and p.name.startswith(prefix)),reverse=True)
    return xs[0] if xs else None

def restore_quarantine(root):
    q=latest(root,"_FILE_QUARANTINE_")
    if not q or not (q/"undo.csv").exists(): print("Карантин не найден."); return
    rows=list(csv.DictReader((q/"undo.csv").open(encoding="utf-8-sig")))
    ok=skip=0
    for r in reversed(rows):
        src,dst=Path(r["quarantine"]),Path(r["original"])
        if not src.exists() or dst.exists(): skip+=1; continue
        try:
            dst.parent.mkdir(parents=True,exist_ok=True); shutil.move(str(src),str(dst)); ok+=1
        except Exception: skip+=1
    print(f"\nВозвращено: {ok}\nПропущено: {skip}")

def ask_rules():
    print("\nМожно добавить проекты/клиентов через запятую. Если не нужно — Enter.")
    projects=[clean(x.strip()) for x in input("Проекты: ").split(",") if x.strip()]
    generic=set(GENERIC)
    print("Игнорируются: разное, старое, копия, backup, old, temp, новая папка")
    generic.update(x.strip().lower() for x in input("Добавить ещё или Enter: ").split(",") if x.strip())
    return projects,generic

def detect_project(rel,text,projects,generic):
    ctx=(" ".join(rel.parts)+" "+text[:5000]).lower()
    for project in projects:
        if project.lower() in ctx: return project
    if len(rel.parts)>1 and not rel.parts[0].startswith("_"):
        first=clean(rel.parts[0])
        if first.lower() not in generic: return first
    return "Без проекта"

def build_plan(root,projects,generic):
    print("\nСтрою будущую структуру...")
    files=list(iter_files(root)); exif=exif_dates([p for p in files if p.suffix.lower() in MEDIA])
    reserved=set(); plan=[]; summary=Counter(); confidence=Counter()
    for i,p in enumerate(files,1):
        rel=p.relative_to(root); e=p.suffix.lower()
        date,conf,source=infer_date(p,root,exif); year=str(date.year); month=f"{date.year:04d}-{date.month:02d}"
        if e in PHOTO|VIDEO:
            dst=root/"_РАЗОБРАНО"/"02_ФОТО_И_ВИДЕО"/year/month
            if len(rel.parts)>1:
                first=clean(rel.parts[0])
                if first.lower() not in generic and not first.startswith("_"): dst/=first
            dst/=p.name; group="Фото/видео"
        elif e in AUDIO:
            dst=root/"_РАЗОБРАНО"/"03_АУДИО"/year/p.name; group="Аудио"
        elif e in WORK:
            text=extract_text(p); ctx=(" ".join(rel.parts)+" "+text)
            dtype=document_type(ctx,e)
            if any(w in ctx.lower() for w in PRIVATE):
                dst=root/"_РАЗОБРАНО"/"04_ЛИЧНОЕ"/dtype/year/p.name; group="Личное"
            else:
                dst=root/"_РАЗОБРАНО"/"01_РАБОТА"/detect_project(rel,text,projects,generic)/dtype/year/p.name; group="Работа"
        elif e in CODE:
            continue
        else:
            dst=root/"_РАЗОБРАНО"/"90_ПРОЧЕЕ"/year/p.name; group="Прочее"
        dst=unique_path(dst,reserved)
        try: size=p.stat().st_size
        except Exception: size=0
        plan.append({"source":str(p),"destination":str(dst),"group":group,"date":date.isoformat(sep=" ",timespec="seconds"),
                     "date_source":source,"confidence":conf,"size_bytes":size})
        summary[group]+=1
        confidence["Высокая" if conf>=85 else "Средняя" if conf>=60 else "Низкая"]+=1
        if i%500==0: print(f"Обработано {i}/{len(files)}")
    out=root/f"_ORGANIZER_PLAN_{stamp()}"; out.mkdir()
    write_csv(out/"plan.csv",["source","destination","group","date","date_source","confidence","size_bytes"],plan)
    print("\n"+"="*48+"\nПЛАН ГОТОВ\n"+"="*48)
    print(f"\nФайлов: {len(plan)}")
    for k,v in summary.items(): print(f"{k:<16} {v}")
    print("\nУверенность даты:")
    for k in ("Высокая","Средняя","Низкая"): print(f"{k:<16} {confidence[k]}")
    print(f"\nПолный план:\n{out/'plan.csv'}")
    return plan

def apply_plan(root,plan):
    if input("\nВведите РАЗОБРАТЬ для начала: ").strip()!="РАЗОБРАТЬ":
        print("Ничего не перемещено."); return
    run=root/f"_ORGANIZER_RUN_{stamp()}"; run.mkdir()
    undo=[]; moved=skipped=0
    for i,r in enumerate(plan,1):
        src,dst=Path(r["source"]),Path(r["destination"])
        if not src.exists(): skipped+=1; continue
        dst.parent.mkdir(parents=True,exist_ok=True)
        if dst.exists(): dst=unique_path(dst)
        try:
            shutil.move(str(src),str(dst)); undo.append({"original":str(src),"current":str(dst)}); moved+=1
        except Exception: skipped+=1
        if i%500==0: print(f"Перемещено {i}/{len(plan)}")
    write_csv(run/"undo.csv",["original","current"],undo)
    print(f"\nПеремещено: {moved}\nПропущено: {skipped}\nНовая структура:\n{root/'_РАЗОБРАНО'}")
    subprocess.run(["open",str(root/"_РАЗОБРАНО")],check=False)

def undo_sort(root):
    run=latest(root,"_ORGANIZER_RUN_")
    if not run or not (run/"undo.csv").exists(): print("Сортировка не найдена."); return
    rows=list(csv.DictReader((run/"undo.csv").open(encoding="utf-8-sig")))
    ok=skip=0
    for r in reversed(rows):
        src,dst=Path(r["current"]),Path(r["original"])
        if not src.exists() or dst.exists(): skip+=1; continue
        try:
            dst.parent.mkdir(parents=True,exist_ok=True); shutil.move(str(src),str(dst)); ok+=1
        except Exception: skip+=1
    print(f"\nВозвращено: {ok}\nПропущено: {skip}")

def main():
    if sys.platform!="darwin": print("Этот вариант рассчитан на macOS."); return
    root=choose_folder()
    print(f"\nВыбрана папка:\n{root}\n\n1 — проверить и разобрать\n2 — откатить последнюю сортировку\n3 — вернуть последний карантин\n4 — выйти")
    choice=input("\nВведите 1, 2, 3 или 4: ").strip()
    if choice=="2": undo_sort(root); return
    if choice=="3": restore_quarantine(root); return
    if choice!="1": return

    groups=audit(root)
    print("\n1 — закончить\n2 — убрать точные дубли в карантин\n3 — перейти к сортировке")
    choice=input("\nВведите 1, 2 или 3: ").strip()
    if choice=="1": return
    if choice=="2":
        quarantine(root,groups)
        if input("\nПродолжить сортировку? Введите ДА или Enter: ").strip()!="ДА": return

    projects,generic=ask_rules()
    plan=build_plan(root,projects,generic)
    print("\n1 — только посмотреть план\n2 — разложить файлы")
    if input("\nВведите 1 или 2: ").strip()=="2": apply_plan(root,plan)
    else: print("\nНичего не перемещено.")

if __name__=="__main__":
    main()
PY

if ! command -v python3 >/dev/null 2>&1; then
    echo
    echo "Python 3 не найден. Установите Python 3 и повторите запуск."
    rm -f "$TMP_SCRIPT"
    exit 1
fi

python3 "$TMP_SCRIPT"
STATUS=$?
rm -f "$TMP_SCRIPT"

if [ "$STATUS" -ne 0 ]; then
    echo
    echo "Скрипт завершился с ошибкой: $STATUS"
fi

Вставляем его в Terminal и нажимаем Ентер! (то есть запускаем).


Шаг 3. Выбрать папку

Откроется стандартное окно macOS. Выбираем каталог, который нужно разобрать (можно заранее все скинуть в 1 место и с рабочего стола и с документов и с папок и тд, все что будем разбирать проверять на дубли и тд).

После выбора появится меню:

1 — проверить и разобрать
2 — откатить последнюю сортировку
3 — вернуть последний карантин
4 — выйти

При первом запуске выбираем 1.


Шаг 4. Дождаться анализа

Сначала скрипт ничего не перемещает. Он сканирует каталог, группирует файлы по размеру и считает SHA-256 для потенциальных дублей.

В моём случае результат был таким:

Файлов: 14 422
Размер: 108,5 ГБ

Групп дублей: 2 982
Лишних копий: 3 203
Объём дублей: 26,8 ГБ

Краткий отчёт сохраняется в FILEAUDIT_.../summary.json.

После проверки Terminal предложит:

1 — закончить
2 — убрать точные дубли в карантин
3 — перейти к сортировке

Шаг 5. Отправить точные дубли в карантин

Выбираем 2 и вводим:

КАРАНТИН

Файлы не удаляются. Лишние копии перемещаются в папку FILEQUARANTINE_... с сохранением исходной структуры каталогов.

Перед переносом SHA-256 проверяется ещё раз, а внутри карантина создаётся undo.csv для возврата файлов.

После этого вводим ДА, если хотим перейти к сортировке. При желании можно указать названия проектов или клиентов через запятую — либо просто нажать Enter.


Шаг 6. Проверить plan.csv и запустить сортировку

Скрипт создаёт папку ORGANIZERPLAN_... и сохраняет внутри plan.csv.

В plan.csv можно заранее проверить:

  • исходный путь;

  • будущий путь;

  • группу;

  • определённую дату;

  • источник даты;

  • уровень уверенности.

После этого появится:

1 — только посмотреть план
2 — разложить файлы

Если всё устраивает, выбираем 2 и подтверждаем реальное перемещение:

РАЗОБРАТЬ

Только после этого файлы начинают перемещаться. Итоговая структура будет примерно такой:

_РАЗОБРАНО/
├── 01_РАБОТА/
│   └── Проект/
│       └── Документы/
│           └── 2026/
├── 02_ФОТО_И_ВИДЕО/
│   └── 2026/
│       └── 2026-08/
├── 03_АУДИО/
│   └── 2026/
├── 04_ЛИЧНОЕ/
│   └── Документы/
│       └── 2026/
└── 90_ПРОЧЕЕ/
    └── 2026/

Для рабочих файлов дополнительно создаются категории вроде Договоры, Акты, Счета, ТЗ, Отчёты, SEO и реклама, Таблицы, Презентации, Дизайн.

Одинаковые имена не перезаписываются: появятся file__2.pdf, file__3.pdf и так далее

Если результат не понравился, запускаем код ещё раз и выбираем 2 — откатить последнюю сортировку. Для возврата карантина — 3 — вернуть последний карантин.

Важно: для фото и видео дата определяется по цепочке EXIF/QuickTime → имя файла → название папки → Spotlight → системная дата macOS. Если установлен ExifTool, он используется автоматически:

brew install exiftool

plan.csv лучше проверить до команды РАЗОБРАТЬ.

Если Terminal показывает Operation not permitted, откройте: Системные настройки → Конфиденциальность и безопасность → Полный доступ к дискуи разрешите доступ Terminal.

Перед первым запуском я всё же рекомендую сделать резервную копию важных данных.

Что получилось у меня когда разбирал

Для первого запуска я взял не все 860 ГБ, а одну крупную папку объёмом 108,5 ГБ.

Результат:

  • 14 422 файла;

  • 2 982 группы точных дублей;

  • 3 203 лишние копии;

  • 26,8 ГБ дублей.

Получается, точные копии заняли около 24,7% проверенного объёма.

Показатель

Проверено

Прогноз на весь архив

Объём

108,5 ГБ

860 ГБ

Файлы

14 422

~114 313

Лишние копии

3 203

~25 388

Точные дубли

26,8 ГБ

~212,4 ГБ

После очистки

81,7 ГБ

~647,6 ГБ

212,4 ГБ — это прогноз, а не уже найденные дубли. Пока точно подтверждены только результаты проверки 108,5 ГБ.

Если примерно такая же доля сохранится в остальных каталогах, после удаления точных копий архив может уменьшиться с 860 до примерно 648 ГБ.

Что скрипт пока не умеет

В данном виде, он ищет файлы одинакового размера с одинаковым SHA-256, то есть точные дубли с практически ничтожной вероятностью случайного совпадения.

Поэтому отдельно остаются:

  • похожие фотографии;

  • одно изображение в разных разрешениях;

  • перекодированные видео;

  • изменённые версии документов;

  • разные версии PSD;

  • просто неудачные или ненужные фотографии.

Это уже следующий этап, возможно дополню статью позже. Если будут какие то пожелания или замечания, буду рад ответить в комментариях.