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

View File

@@ -0,0 +1,336 @@
"""Формирование рекомендаций по результатам сканирования."""
from __future__ import annotations
import os
from collections import defaultdict
from .model import Category, Finding, Recommendation, Risk, ScanResult
from .util import human
GB = 1024 ** 3
MB = 1024 ** 2
def _plural(n: int, one: str, few: str, many: str) -> str:
n = abs(n) % 100
if 11 <= n <= 14:
return many
n %= 10
if n == 1:
return one
if 2 <= n <= 4:
return few
return many
def build_recommendations(res: ScanResult) -> list[Recommendation]:
recs: list[Recommendation] = []
by_cat = res.by_category()
# --- 1. Свободное место на дисках --------------------------------------
for d in res.disks:
if not d["total"]:
continue
free_pct = d["free"] / d["total"] * 100
if free_pct < 10:
recs.append(Recommendation(
f"Диск {d['root']} почти заполнен",
f"Свободно {human(d['free'])} из {human(d['total'])} ({free_pct:.0f}%). "
"Ниже 10% Windows начинает тормозить: не хватает места под файл подкачки, "
"точки восстановления и обновления. Освободите хотя бы "
f"{human(int(d['total'] * 0.15 - d['free']))}.",
severity=2,
))
elif free_pct < 20:
recs.append(Recommendation(
f"На диске {d['root']} мало места",
f"Свободно {human(d['free'])} ({free_pct:.0f}%). Запас меньше 20% — "
"стоит почистить заранее.",
severity=1,
))
# --- 2. Быстрая победа: всё безопасное ---------------------------------
safe = [f for f in res.findings if f.risk == Risk.SAFE]
safe_total = sum(f.size for f in safe)
if safe_total > 50 * MB:
recs.append(Recommendation(
"Безопасная очистка одним действием",
f"{len(safe)} {_plural(len(safe), 'находка', 'находки', 'находок')} помечены как "
f"полностью безопасные: временные файлы, кеши браузеров, дампы, эскизы. "
f"Освободится {human(safe_total)}. Ничего из пользовательских данных "
"не затрагивается, всё пересоздаётся системой автоматически.",
gain=safe_total, severity=2, findings=safe,
))
# --- 3. Разработка ------------------------------------------------------
dev_cache = by_cat.get(Category.DEV_CACHE, [])
dev_proj = by_cat.get(Category.DEV_PROJECT, [])
dev_total = sum(f.size for f in dev_cache) + sum(f.size for f in dev_proj)
if dev_total > 500 * MB:
eco: dict[str, int] = defaultdict(int)
for f in dev_proj:
eco[f.extra.get("ecosystem") or "Прочее"] += f.size
top = ", ".join(f"{k} — {human(v)}"
for k, v in sorted(eco.items(), key=lambda kv: -kv[1])[:4])
body = (f"Кеши инструментов ({human(sum(f.size for f in dev_cache))}) и артефакты "
f"сборки внутри проектов ({human(sum(f.size for f in dev_proj))}) — "
f"вместе {human(dev_total)}.")
if top:
body += f"\nПо экосистемам: {top}."
body += ("\nВсё это восстанавливается командами установки зависимостей и сборки. "
"Начните с проектов, которые давно не открывали.")
recs.append(Recommendation("Разработка занимает больше всего места", body,
gain=dev_total, severity=2,
findings=dev_cache + dev_proj))
# --- 4. Заброшенные проекты --------------------------------------------
stale = [f for f in dev_proj if f.age_days > 180 and f.size > 100 * MB]
if stale:
stale.sort(key=lambda f: -f.size)
gain = sum(f.size for f in stale)
listing = "\n".join(
f" • {human(f.size):>9} {f.extra.get('project', f.path)} "
f"(не менялся {int(f.age_days)} дн.)" for f in stale[:8])
recs.append(Recommendation(
"Артефакты в заброшенных проектах",
f"{len(stale)} {_plural(len(stale), 'каталог', 'каталога', 'каталогов')} "
f"артефактов в проектах, к которым не притрагивались больше полугода — "
f"{human(gain)}.\n{listing}",
gain=gain, severity=2, findings=stale,
))
# --- 5. Дублирующиеся node_modules -------------------------------------
nm = [f for f in dev_proj if f.extra.get("artifact") == "node_modules"]
if len(nm) >= 5:
gain = sum(f.size for f in nm)
recs.append(Recommendation(
"Много копий node_modules",
f"Найдено {len(nm)} каталогов node_modules общим объёмом {human(gain)}. "
"Если переключиться на pnpm, пакеты будут храниться в одном месте и "
"переиспользоваться между проектами — экономия обычно в разы.",
gain=gain, severity=1, findings=nm,
))
# --- 6. Браузеры --------------------------------------------------------
br = by_cat.get(Category.BROWSERS, [])
br_total = sum(f.size for f in br)
if br_total > 500 * MB:
busy = sorted({p for f in br for p in f.extra.get("busy", [])})
body = f"Кеши браузеров занимают {human(br_total)}. Удаление не трогает пароли, " \
"закладки и историю — только временные копии страниц."
if busy:
body += f"\n⚠ Сейчас запущены: {', '.join(busy)}. Закройте браузер перед очисткой, " \
"иначе часть файлов останется заблокированной."
recs.append(Recommendation("Кеши браузеров разрослись", body,
gain=br_total, severity=1, findings=br))
# --- 7. Крупные программы, которыми не пользуются -----------------------
idle = [p for p in res.programs
if p.best_size > 500 * MB and 0 < p.idle_days > 365]
if idle:
idle.sort(key=lambda p: -p.best_size)
gain = sum(p.best_size for p in idle)
listing = "\n".join(
f" • {human(p.best_size):>9} {p.name} "
f"(файлы не менялись {int(p.idle_days)} дн.)" for p in idle[:8])
recs.append(Recommendation(
"Тяжёлые программы без признаков использования",
f"{len(idle)} {_plural(len(idle), 'программа', 'программы', 'программ')} "
f"весом от 500 МБ, файлы которых не изменялись больше года — {human(gain)}.\n"
f"{listing}\nПроверьте список и удалите ненужное через вкладку «Программы».",
gain=gain, severity=1,
))
# --- 8. Самые тяжёлые программы ----------------------------------------
if res.programs:
top = [p for p in res.programs if p.best_size > 0][:5]
if top and top[0].best_size > GB:
listing = "\n".join(f" • {human(p.best_size):>9} {p.name}" for p in top)
recs.append(Recommendation(
"Самые тяжёлые установленные программы",
f"Установлено {len(res.programs)} программ. Крупнейшие:\n{listing}",
severity=0,
))
# --- 9. Windows.old и обновления ---------------------------------------
winold = [f for f in res.findings if f.extra.get("rule") == "win.old"]
if winold:
gain = sum(f.size for f in winold)
recs.append(Recommendation(
"Осталась предыдущая версия Windows",
f"Каталог Windows.old занимает {human(gain)}. Он нужен только для отката "
"на прошлую сборку Windows. Если система работает стабильно несколько недель — "
"каталог можно удалить.",
gain=gain, severity=2, findings=winold,
))
# --- 10. Дубликаты ------------------------------------------------------
dups = by_cat.get(Category.DUPLICATES, [])
if dups:
gain = sum(f.size for f in dups)
recs.append(Recommendation(
"Найдены одинаковые файлы",
f"{len(dups)} {_plural(len(dups), 'группа', 'группы', 'групп')} файлов с "
f"идентичным содержимым — {human(gain)} потрачено впустую. "
"Приложение предлагает удалить все копии кроме одной, но проверьте список: "
"иногда копии лежат в разных проектах намеренно.",
gain=gain, severity=1, findings=dups,
))
# --- 11. Крупные файлы --------------------------------------------------
bigs = by_cat.get(Category.BIG_FILES, [])
if bigs:
junk = [f for f in bigs if f.risk == Risk.MEDIUM]
if junk:
gain = sum(f.size for f in junk)
listing = "\n".join(f" • {human(f.size):>9} {f.path}" for f in junk[:8])
recs.append(Recommendation(
"Крупные архивы, образы и установщики",
f"{len(junk)} {_plural(len(junk), 'файл', 'файла', 'файлов')} на {human(gain)}: "
f"ISO, архивы, установщики, дампы и бэкапы — обычно их скачивают один раз "
f"и забывают.\n{listing}",
gain=gain, severity=1, findings=junk,
))
# --- 12. Риски ----------------------------------------------------------
risky = [f for f in res.findings if f.risk >= Risk.MEDIUM]
if risky:
gain = sum(f.size for f in risky)
recs.append(Recommendation(
"Что требует вашего решения",
f"{len(risky)} {_plural(len(risky), 'находка', 'находки', 'находок')} "
f"({human(gain)}) помечены средним или высоким риском: виртуальные окружения, "
"локальные репозитории пакетов, кеши Unity/Unreal, Windows.old. "
"Они восстанавливаются, но на это уйдёт время, а иногда и трафик. "
"Такие пункты никогда не отмечаются автоматически.",
gain=gain, severity=0, findings=risky,
))
# --- 13. Нет прав администратора ---------------------------------------
need_admin = [f for f in res.findings if f.extra.get("needs_admin")]
if need_admin:
from .util import is_admin
if not is_admin():
gain = sum(f.size for f in need_admin)
recs.append(Recommendation(
"Часть находок требует прав администратора",
f"{len(need_admin)} {_plural(len(need_admin), 'пункт', 'пункта', 'пунктов')} "
f"({human(gain)}) — системные каталоги: Windows\\Temp, кеш обновлений, "
"оптимизация доставки. Запустите приложение от имени администратора, "
"чтобы очистить и их.",
gain=gain, severity=1, findings=need_admin,
))
recs.sort(key=lambda r: (-r.severity, -r.gain))
return recs
def default_selection(findings: list[Finding]) -> None:
"""Отметить то, что безопасно чистить без раздумий."""
for f in findings:
f.selected = (
f.risk == Risk.SAFE
and f.category not in (Category.BIG_FILES, Category.DUPLICATES)
and not f.extra.get("busy")
)
def plan_summary(selected: list[Finding]) -> str:
"""Текст подтверждения перед очисткой."""
if not selected:
return "Ничего не выбрано."
total = sum(f.size for f in selected)
by_risk: dict[Risk, list[Finding]] = defaultdict(list)
for f in selected:
by_risk[f.risk].append(f)
lines = [f"К удалению: {len(selected)} "
f"{_plural(len(selected), 'объект', 'объекта', 'объектов')}, "
f"освободится примерно {human(total)}.", ""]
for risk in sorted(by_risk, reverse=True):
items = by_risk[risk]
lines.append(f"{risk.label}: {len(items)} — {human(sum(f.size for f in items))}")
for f in sorted(items, key=lambda x: -x.size)[:6]:
mode = "содержимое" if f.delete_contents_only else "целиком"
lines.append(f" {human(f.size):>9} {f.title} ({mode})")
if len(items) > 6:
lines.append(f" … и ещё {len(items) - 6}")
lines.append("")
busy = sorted({p for f in selected for p in f.extra.get("busy", [])})
if busy:
lines.append(f"⚠ Запущены программы: {', '.join(busy)}. "
"Часть файлов может остаться заблокированной.")
return "\n".join(lines)
def report_text(res: ScanResult) -> str:
"""Полный текстовый отчёт — для сохранения в файл."""
import time as _t
L: list[str] = []
L.append("=" * 78)
L.append(" SysDevCleaner — отчёт об анализе системы")
L.append(f" {_t.strftime('%d.%m.%Y %H:%M', _t.localtime(res.started))} "
f"(сканирование заняло {res.duration:.0f} с)")
L.append("=" * 78)
L.append("")
L.append("ДИСКИ")
for d in res.disks:
if not d["total"]:
continue
bar_len = 30
filled = int(d["percent"] / 100 * bar_len)
bar = "█" * filled + "·" * (bar_len - filled)
L.append(f" {d['root']:<4} [{bar}] занято {d['percent']:.0f}% "
f"свободно {human(d['free'])} из {human(d['total'])}")
L.append("")
L.append(f"ИТОГО НАЙДЕНО: {human(res.total())} в {len(res.findings)} местах")
L.append("")
L.append("ПО КАТЕГОРИЯМ")
for cat, items in sorted(res.by_category().items(),
key=lambda kv: -sum(f.size for f in kv[1])):
L.append(f" {cat.icon} {cat.label:<28} {human(sum(f.size for f in items)):>10} "
f"({len(items)})")
L.append("")
if res.recommendations:
L.append("РЕКОМЕНДАЦИИ")
for i, r in enumerate(res.recommendations, 1):
mark = {0: "i", 1: "•", 2: "!"}[r.severity]
head = f" [{mark}] {i}. {r.title}"
if r.gain:
head += f" — {human(r.gain)}"
L.append(head)
for line in r.body.splitlines():
L.append(f" {line}")
L.append("")
L.append("ПОДРОБНО")
for cat, items in sorted(res.by_category().items(),
key=lambda kv: -sum(f.size for f in kv[1])):
L.append("")
L.append(f" {cat.icon} {cat.label} — {human(sum(f.size for f in items))}")
L.append(" " + "-" * 74)
for f in items[:60]:
L.append(f" {human(f.size):>10} [{f.risk.label:<14}] {f.title}")
L.append(f" {f.path}")
if len(items) > 60:
L.append(f" … и ещё {len(items) - 60} пунктов")
if res.programs:
L.append("")
L.append("УСТАНОВЛЕННЫЕ ПРОГРАММЫ (топ-40 по размеру)")
L.append(" " + "-" * 74)
for p in res.programs[:40]:
idle = f"{int(p.idle_days)} дн." if p.idle_days >= 0 else "—"
L.append(f" {human(p.best_size):>10} {p.name[:46]:<46} простой: {idle}")
if res.errors:
L.append("")
L.append(f"ОШИБКИ ДОСТУПА: {len(res.errors)} (обычно это занятые или "
"защищённые системой файлы)")
for e in res.errors[:20]:
L.append(f" {e}")
return "\n".join(L)