Первый выпуск 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

188
sysdevcleaner/cli.py Normal file
View File

@@ -0,0 +1,188 @@
"""Консольный режим SysDevCleaner — для скриптов и быстрой проверки."""
from __future__ import annotations
import argparse
import sys
from .core import cleaner as cleanmod
from .core.advisor import report_text
from .core.model import Category, Risk
from .core.scanner import (ScanOptions, default_project_roots, full_scan,
normalize_drives)
from .core.util import Cancel, human, is_admin
CAT_KEYS = {
"windows": Category.WINDOWS,
"browsers": Category.BROWSERS,
"devcache": Category.DEV_CACHE,
"projects": Category.DEV_PROJECT,
"apps": Category.APPS,
"logs": Category.LOGS,
"pkg": Category.PACKAGE_MANAGERS,
}
RISK_KEYS = {"safe": Risk.SAFE, "low": Risk.LOW, "medium": Risk.MEDIUM, "high": Risk.HIGH}
def out(text: str = "", **kw) -> None:
"""print, устойчивый к кодировке консоли.
В русской Windows консоль обычно cp1251: кириллица в неё ложится, а эмодзи
из названий категорий — нет, и обычный print падает с UnicodeEncodeError.
Заменяем то, что консоль не осилит, на безопасный символ.
"""
enc = getattr(sys.stdout, "encoding", None) or "utf-8"
try:
text.encode(enc)
except (UnicodeEncodeError, LookupError):
text = text.encode(enc, "replace").decode(enc, "replace")
print(text, **kw)
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="sysdevcleaner",
description="Анализ и очистка кешей, мусора и остатков разработки.")
p.add_argument("--gui", action="store_true", help="запустить графический интерфейс")
p.add_argument("--categories", default="all",
help="через запятую: " + ",".join(CAT_KEYS) + " или all")
p.add_argument("--root", action="append", default=[],
help="папка для поиска проектов (можно указать несколько раз)")
p.add_argument("--drive", action="append", default=[], metavar="БУКВА",
help="сканировать только указанный диск, например --drive D "
"(можно указать несколько раз; по умолчанию — все)")
p.add_argument("--big-files", type=int, metavar="МБ", default=0,
help="искать файлы крупнее указанного размера")
p.add_argument("--duplicates", type=int, metavar="МБ", default=0,
help="искать дубликаты файлов крупнее указанного размера")
p.add_argument("--no-programs", action="store_true", help="не собирать список программ")
p.add_argument("--report", metavar="ФАЙЛ", help="сохранить подробный отчёт")
p.add_argument("--top", type=int, default=30, help="сколько находок показать (по умолчанию 30)")
p.add_argument("--clean", metavar="РИСК", choices=list(RISK_KEYS),
help="удалить находки с риском не выше указанного")
p.add_argument("--permanent", action="store_true",
help="удалять мимо Корзины (по умолчанию — в Корзину)")
p.add_argument("--dry-run", action="store_true", help="показать план, ничего не удаляя")
p.add_argument("--yes", action="store_true", help="не спрашивать подтверждение")
return p
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
if args.gui:
from .__main__ import launch_gui
return launch_gui()
if args.categories.strip().lower() == "all":
cats = set(CAT_KEYS.values())
else:
cats = set()
for key in args.categories.split(","):
key = key.strip().lower()
if key not in CAT_KEYS:
print(f"Неизвестная категория: {key}", file=sys.stderr)
return 2
cats.add(CAT_KEYS[key])
opts = ScanOptions(
categories=cats,
project_roots=args.root or default_project_roots(),
scan_big_files=args.big_files > 0,
big_file_min=max(1, args.big_files) * 1024 * 1024,
scan_duplicates=args.duplicates > 0,
duplicate_min=max(1, args.duplicates) * 1024 * 1024,
scan_programs=not args.no_programs,
drives=normalize_drives(args.drive),
)
cancel = Cancel()
last = [""]
def progress(msg: str, frac: float) -> None:
line = f" [{frac * 100:5.1f}%] {msg}"[:110]
if line != last[0]:
last[0] = line
out(line.ljust(112), end="\r", flush=True)
scope = ("диски: " + ", ".join(sorted(opts.drives))) if opts.drives else "все диски"
out(f"SysDevCleaner · сканирование ({scope})"
f"{'' if is_admin() else ' · без прав администратора'}…")
try:
res = full_scan(opts, cancel, progress)
except KeyboardInterrupt:
cancel.cancel()
out("\nПрервано.")
return 130
out(" " * 112, end="\r")
out(f"\nНайдено {human(res.total())} в {len(res.findings)} местах "
f"за {res.duration:.0f} с\n")
for cat, items in sorted(res.by_category().items(),
key=lambda kv: -sum(f.size for f in kv[1])):
out(f" {cat.icon} {cat.label:<28} {human(sum(f.size for f in items)):>10} "
f"({len(items)})")
out(f"\nКрупнейшие находки (топ-{args.top}):")
for f in res.findings[:args.top]:
out(f" {human(f.size):>10} [{f.risk.label:<14}] {f.title}")
out(f" {f.path}")
if res.recommendations:
out("\nРекомендации:")
for i, r in enumerate(res.recommendations, 1):
gain = f" — {human(r.gain)}" if r.gain else ""
out(f" {i}. {r.title}{gain}")
if args.report:
try:
with open(args.report, "w", encoding="utf-8") as fh:
fh.write(report_text(res))
out(f"\nОтчёт сохранён: {args.report}")
except OSError as e:
print(f"\nНе удалось сохранить отчёт: {e}", file=sys.stderr)
if args.clean:
limit = RISK_KEYS[args.clean]
targets = [f for f in res.findings
if f.risk <= limit
and f.category not in (Category.BIG_FILES, Category.DUPLICATES)]
if not targets:
out("\nНечего удалять с таким уровнем риска.")
return 0
total = sum(f.size for f in targets)
out(f"\nК удалению: {len(targets)} объектов, {human(total)} "
f"(риск не выше «{limit.label}»)")
if args.permanent:
out("Режим: БЕЗВОЗВРАТНОЕ удаление мимо Корзины.")
if args.dry_run:
out("Пробный запуск — ничего не будет удалено.")
if not args.yes and not args.dry_run:
ans = input("Продолжить? [y/N] ").strip().lower()
if ans not in ("y", "yes", "д", "да"):
out("Отменено.")
return 0
cres = cleanmod.clean(
targets,
cleanmod.CleanOptions(to_recycle_bin=not args.permanent, dry_run=args.dry_run),
cancel,
lambda m, f: progress(m, f),
)
out(" " * 112, end="\r")
out("\n" + cres.summary())
fails = [i for i in cres.items if not i.ok]
if fails:
out(f"\nНе удалось очистить {len(fails)}:")
for i in fails[:15]:
out(f" • {i.finding.title}: {i.message or 'файлы заняты'}")
if cres.journal_path:
out(f"\nЖурнал: {cres.journal_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())