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