Первый выпуск 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:
220
sysdevcleaner/core/heavy.py
Normal file
220
sysdevcleaner/core/heavy.py
Normal 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]
|
||||
Reference in New Issue
Block a user