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