Files
SysDevCleaner/sysdevcleaner/core/advisor.py
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

337 lines
17 KiB
Python
Raw 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.
"""Формирование рекомендаций по результатам сканирования."""
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)