Первый выпуск 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>
This commit is contained in:
2026-09-11 14:55:00 +03:00
commit b01cf37e66
33 changed files with 6472 additions and 0 deletions

220
sysdevcleaner/core/heavy.py Normal file
View File

@@ -0,0 +1,220 @@
"""Поиск крупных файлов и дубликатов."""
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]