Files
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

189 lines
8.2 KiB
Python
Raw Permalink 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.
"""Консольный режим 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())