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