Files
SysDevCleaner/sysdevcleaner/core/heavy.py
HeagBoKaT b01cf37e66 Первый выпуск SysDevCleaner
Анализ Windows и очистка кешей, мусора и остатков разработки.

Ядро:
- каталог правил: 228 правил путей (496 шаблонов) + 43 правила артефактов
  проектов — Windows, браузеры, кеши сборки, IDE, приложения, пакетные
  менеджеры;
- рекурсивный поиск node_modules, target, build, __pycache__, .venv и т.п.
  с проверкой маркеров проекта, чтобы не ловить ложные срабатывания;
- поиск крупных файлов и дубликатов (хеш по началу/середине/концу и полная
  сверка), инвентаризация установленных программ через реестр;
- ограничение области сканирования по дискам;
- рекомендации и текстовый отчёт.

Безопасность:
- четыре уровня риска, автоматически отмечается только «Безопасно»;
- список защищённых путей проверяется и при выборе, и перед удалением;
- для каталогов вроде %TEMP% удаляется содержимое, а не сам каталог;
- удаление в Корзину по умолчанию, пробный запуск, журнал очисток.

Интерфейс на Dear PyGui:
- JetBrains Mono Nerd Font: кириллица и иконки в одном шрифте;
- тёмная и светлая темы, адаптивная раскладка;
- заголовок окна ставится через SetWindowTextW — Dear PyGui отдаёт его
  в ANSI-функцию как UTF-8 и портит кириллицу;
- запуск всегда с правами администратора: манифест UAC в exe и
  самоперезапуск через ShellExecuteW при работе из исходников.

Сборка в один exe через PyInstaller со вшитым шрифтом и значком.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 14:55:00 +03:00

221 lines
8.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Поиск крупных файлов и дубликатов."""
from __future__ import annotations
import hashlib
import os
from collections import defaultdict
from typing import Callable, Iterable
from .model import Category, Finding, Risk
from .rules import SKIP_DIRS
from .util import Cancel, human
# Расширения, которые почти всегда — «тяжёлый мусор, о котором забыли»
JUNKY_EXT = {
".iso": "образ диска", ".img": "образ диска", ".vhd": "виртуальный диск",
".vhdx": "виртуальный диск", ".vmdk": "виртуальный диск",
".zip": "архив", ".rar": "архив", ".7z": "архив", ".tar": "архив", ".gz": "архив",
".msi": "установщик", ".exe": "установщик/программа", ".dmg": "образ",
".bak": "резервная копия", ".old": "старая версия", ".tmp": "временный файл",
".dmp": "дамп памяти", ".log": "журнал", ".pkg": "пакет",
}
MEDIA_EXT = {".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".psd", ".ai", ".raw",
".cr2", ".nef", ".wav", ".flac", ".prproj", ".aep"}
def scan_big_files(
roots: Iterable[str],
min_size: int = 100 * 1024 * 1024,
limit: int = 400,
cancel: Cancel | None = None,
progress: Callable[[str], None] | None = None,
max_depth: int = 12,
) -> list[Finding]:
"""Найти файлы крупнее ``min_size``. Ничего не предлагает удалять автоматически."""
out: list[Finding] = []
scanned = 0
for root in roots:
root = os.path.abspath(root)
if not os.path.isdir(root):
continue
stack = [(root, 0)]
while stack:
if cancel is not None and cancel.cancelled:
return _trim(out, limit)
cur, depth = stack.pop()
if depth > max_depth:
continue
try:
entries = list(os.scandir(cur))
except (OSError, ValueError):
continue
scanned += 1
if progress is not None and scanned % 300 == 0:
progress(cur)
for e in entries:
try:
if e.is_dir(follow_symlinks=False):
if e.name.lower() not in SKIP_DIRS:
stack.append((e.path, depth + 1))
continue
if not e.is_file(follow_symlinks=False):
continue
st = e.stat(follow_symlinks=False)
except OSError:
continue
if st.st_size < min_size:
continue
ext = os.path.splitext(e.name)[1].lower()
kind = JUNKY_EXT.get(ext)
risk = Risk.MEDIUM if kind else Risk.HIGH
note = f"Тип: {kind}. " if kind else ""
if ext in MEDIA_EXT:
note = "Медиафайл или исходник проекта. "
out.append(Finding(
path=e.path,
title=e.name,
category=Category.BIG_FILES,
risk=risk,
size=st.st_size, files=1, mtime=st.st_mtime,
detail=(f"{note}{human(st.st_size)}\nПапка: {cur}\n"
"Решение об удалении принимаете вы — приложение ничего "
"не отмечает здесь автоматически."),
group=kind or "файл",
is_dir=False,
))
return _trim(out, limit)
def _trim(items: list[Finding], limit: int) -> list[Finding]:
items.sort(key=lambda f: -f.size)
return items[:limit]
# ------------------------------------------------------------- дубликаты ----
def _quick_hash(path: str, size: int, chunk: int = 65536) -> str | None:
"""Хеш по началу, середине и концу файла — быстрый предварительный фильтр."""
try:
h = hashlib.blake2b(digest_size=16)
with open(path, "rb") as fh:
h.update(fh.read(chunk))
if size > chunk * 3:
fh.seek(size // 2)
h.update(fh.read(chunk))
fh.seek(max(0, size - chunk))
h.update(fh.read(chunk))
h.update(str(size).encode())
return h.hexdigest()
except OSError:
return None
def _full_hash(path: str) -> str | None:
try:
h = hashlib.blake2b(digest_size=16)
with open(path, "rb") as fh:
for block in iter(lambda: fh.read(1 << 20), b""):
h.update(block)
return h.hexdigest()
except OSError:
return None
def scan_duplicates(
roots: Iterable[str],
min_size: int = 5 * 1024 * 1024,
cancel: Cancel | None = None,
progress: Callable[[str], None] | None = None,
max_files: int = 200_000,
verify_full: bool = True,
max_depth: int = 12,
) -> list[Finding]:
"""Найти одинаковые файлы: группировка по размеру → быстрый хеш → полный хеш."""
by_size: dict[int, list[str]] = defaultdict(list)
count = 0
for root in roots:
root = os.path.abspath(root)
if not os.path.isdir(root):
continue
stack = [(root, 0)]
while stack:
if cancel is not None and cancel.cancelled:
return []
cur, depth = stack.pop()
if depth > max_depth:
continue
try:
entries = list(os.scandir(cur))
except (OSError, ValueError):
continue
for e in entries:
try:
if e.is_dir(follow_symlinks=False):
if e.name.lower() not in SKIP_DIRS:
stack.append((e.path, depth + 1))
continue
if not e.is_file(follow_symlinks=False):
continue
st = e.stat(follow_symlinks=False)
except OSError:
continue
if st.st_size < min_size:
continue
by_size[st.st_size].append(e.path)
count += 1
if count >= max_files:
stack.clear()
break
if progress is not None and count % 500 < 2:
progress(cur)
groups: list[Finding] = []
for size, paths in by_size.items():
if len(paths) < 2:
continue
if cancel is not None and cancel.cancelled:
break
buckets: dict[str, list[str]] = defaultdict(list)
for p in paths:
qh = _quick_hash(p, size)
if qh:
buckets[qh].append(p)
for qh, group in buckets.items():
if len(group) < 2:
continue
if verify_full and size <= 512 * 1024 * 1024:
confirmed: dict[str, list[str]] = defaultdict(list)
for p in group:
fh = _full_hash(p)
if fh:
confirmed[fh].append(p)
final_groups = [g for g in confirmed.values() if len(g) > 1]
else:
final_groups = [group]
for g in final_groups:
g.sort(key=lambda p: (len(p), p))
wasted = size * (len(g) - 1)
try:
mtime = os.stat(g[0]).st_mtime
except OSError:
mtime = 0.0
groups.append(Finding(
path=g[1] if len(g) > 1 else g[0], # по умолчанию «лишняя» копия
title=f"{os.path.basename(g[0])} × {len(g)}",
category=Category.DUPLICATES,
risk=Risk.MEDIUM,
size=wasted, files=len(g) - 1, mtime=mtime,
detail=("Одинаковые по содержимому файлы ({} копий по {}).\n"
"Оригинал (оставить): {}\nЛишние копии:\n {}".format(
len(g), human(size), g[0], "\n ".join(g[1:]))),
group="дубликаты",
is_dir=False,
members=g[1:],
extra={"original": g[0], "all": g, "unit": size},
))
groups.sort(key=lambda f: -f.size)
return groups[:300]