Первый выпуск 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 @@
"""Ядро SysDevCleaner."""

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)

View File

@@ -0,0 +1,251 @@
"""Выполнение очистки: удаление выбранных находок с журналом и защитой."""
from __future__ import annotations
import fnmatch
import json
import os
import time
from dataclasses import dataclass, field
from typing import Callable, Iterable
from .model import Finding
from .util import Cancel, clear_contents, delete_path, human, is_safe_target
@dataclass
class CleanOptions:
to_recycle_bin: bool = True # False = удалять безвозвратно
dry_run: bool = False
stop_on_error: bool = False
journal_dir: str = ""
@dataclass
class ItemResult:
finding: Finding
ok: bool
freed: int = 0
removed: int = 0
skipped: int = 0
message: str = ""
@dataclass
class CleanResult:
items: list[ItemResult] = field(default_factory=list)
started: float = 0.0
finished: float = 0.0
cancelled: bool = False
journal_path: str = ""
@property
def freed(self) -> int:
return sum(i.freed for i in self.items)
@property
def ok_count(self) -> int:
return sum(1 for i in self.items if i.ok)
@property
def fail_count(self) -> int:
return sum(1 for i in self.items if not i.ok)
def summary(self) -> str:
dur = max(0.0, (self.finished or time.time()) - self.started)
head = (f"Освобождено {human(self.freed)} · успешно {self.ok_count} · "
f"с ошибками {self.fail_count} · {dur:.0f} с")
if self.cancelled:
head = "ОТМЕНЕНО. " + head
return head
def _free_space_bytes(paths: Iterable[str]) -> dict[str, int]:
import shutil
out: dict[str, int] = {}
for p in paths:
drive = os.path.splitdrive(os.path.abspath(p))[0] or "C:"
root = drive + "\\"
if root in out:
continue
try:
out[root] = shutil.disk_usage(root).free
except OSError:
continue
return out
def _delete_masked_files(path: str, masks: list[str], recycle: bool) -> tuple[int, int, int, list[str]]:
"""Удалить в каталоге только файлы, подходящие под маски."""
freed = removed = skipped = 0
errs: list[str] = []
try:
entries = list(os.scandir(path))
except OSError as e:
return 0, 0, 0, [str(e)]
for e in entries:
try:
if not e.is_file(follow_symlinks=False):
continue
if not any(fnmatch.fnmatch(e.name.lower(), m.lower()) for m in masks):
continue
size = e.stat(follow_symlinks=False).st_size
except OSError:
continue
ok, err = delete_path(e.path, recycle)
if ok:
removed += 1
freed += size
else:
skipped += 1
if err and len(errs) < 10:
errs.append(f"{e.name}: {err}")
return freed, removed, skipped, errs
def clean(
findings: Iterable[Finding],
opts: CleanOptions,
cancel: Cancel | None = None,
progress: Callable[[str, float], None] | None = None,
) -> CleanResult:
"""Удалить выбранные находки. Возвращает подробный результат по каждой."""
items = list(findings)
res = CleanResult(started=time.time())
total = max(1, len(items))
before = _free_space_bytes(f.path for f in items)
journal: list[dict] = []
for i, f in enumerate(items):
if cancel is not None and cancel.cancelled:
res.cancelled = True
break
if progress is not None:
progress(f"{f.title} — {human(f.size)}", i / total)
safe, why = is_safe_target(f.path)
if not f.delete_contents_only and not safe:
res.items.append(ItemResult(f, False, message=f"пропущено: {why}"))
continue
if not os.path.exists(f.path):
res.items.append(ItemResult(f, True, message="уже отсутствует"))
continue
if opts.dry_run:
res.items.append(ItemResult(f, True, freed=f.size, removed=f.files,
message="пробный запуск — ничего не удалено"))
continue
masks = f.extra.get("file_masks") or []
try:
if f.members: # группа дубликатов
freed = removed = skipped = 0
errs: list[str] = []
unit = int(f.extra.get("unit") or 0)
for member in f.members:
ok, err = delete_path(member, opts.to_recycle_bin)
if ok:
removed += 1
freed += unit
else:
skipped += 1
if err:
errs.append(f"{os.path.basename(member)}: {err}")
item = ItemResult(f, skipped == 0, freed, removed, skipped,
"; ".join(errs[:3]))
elif masks:
freed, removed, skipped, errs = _delete_masked_files(
f.path, masks, opts.to_recycle_bin)
item = ItemResult(f, skipped == 0, freed, removed, skipped,
"; ".join(errs[:3]))
elif f.delete_contents_only:
done, skipped, errs = clear_contents(f.path, opts.to_recycle_bin)
# оцениваем освобождённое по доле удалённых объектов
share = done / max(1, done + skipped)
item = ItemResult(f, skipped == 0, int(f.size * share), done, skipped,
"; ".join(errs[:3]))
else:
ok, err = delete_path(f.path, opts.to_recycle_bin)
item = ItemResult(f, ok, f.size if ok else 0, 1 if ok else 0,
0 if ok else 1, err)
except Exception as e: # noqa: BLE001
item = ItemResult(f, False, message=str(e))
res.items.append(item)
journal.append({
"time": time.strftime("%Y-%m-%d %H:%M:%S"),
"path": f.path,
"title": f.title,
"category": f.category.label,
"risk": f.risk.label,
"mode": ("содержимое" if f.delete_contents_only else
"копии" if f.members else "целиком"),
"recycle_bin": opts.to_recycle_bin,
"ok": item.ok,
"freed": item.freed,
"removed": item.removed,
"skipped": item.skipped,
"message": item.message,
})
if opts.stop_on_error and not item.ok:
break
res.finished = time.time()
# уточняем реально освобождённое место по свободному объёму дисков
after = _free_space_bytes(f.path for f in items)
res_real = sum(max(0, after.get(k, 0) - v) for k, v in before.items())
if opts.dry_run:
res_real = 0
if journal and not opts.dry_run:
res.journal_path = _write_journal(journal, opts, res, res_real)
if progress is not None:
progress("Очистка завершена", 1.0)
return res
def _write_journal(entries: list[dict], opts: CleanOptions,
res: CleanResult, real_freed: int) -> str:
base = opts.journal_dir or os.path.join(
os.environ.get("LOCALAPPDATA", os.path.expanduser("~")), "SysDevCleaner", "journal")
try:
os.makedirs(base, exist_ok=True)
name = time.strftime("clean-%Y%m%d-%H%M%S.json")
path = os.path.join(base, name)
with open(path, "w", encoding="utf-8") as fh:
json.dump({
"started": res.started,
"finished": res.finished,
"to_recycle_bin": opts.to_recycle_bin,
"estimated_freed": res.freed,
"disk_delta": real_freed,
"items": entries,
}, fh, ensure_ascii=False, indent=2)
return path
except OSError:
return ""
def list_journals(limit: int = 30) -> list[tuple[str, str, int]]:
"""Последние журналы очистки: (путь, дата, освобождено)."""
base = os.path.join(os.environ.get("LOCALAPPDATA", os.path.expanduser("~")),
"SysDevCleaner", "journal")
if not os.path.isdir(base):
return []
out: list[tuple[str, str, int]] = []
try:
names = sorted(os.listdir(base), reverse=True)[:limit]
except OSError:
return []
for n in names:
p = os.path.join(base, n)
try:
with open(p, encoding="utf-8") as fh:
data = json.load(fh)
out.append((p, time.strftime("%d.%m.%Y %H:%M",
time.localtime(data.get("started", 0))),
int(data.get("estimated_freed", 0))))
except Exception:
continue
return out

View File

@@ -0,0 +1,188 @@
"""Рекурсивный поиск артефактов сборки и зависимостей внутри проектов."""
from __future__ import annotations
import fnmatch
import os
from typing import Callable, Iterable
from .model import Category, Finding
from .rules import NEVER_DESCEND, PROJECT_RULES, SKIP_DIRS, ProjectRule
from .util import Cancel, dir_stats, human
# имя каталога -> список правил с таким именем
_BY_NAME: dict[str, list[ProjectRule]] = {}
for _r in PROJECT_RULES:
_BY_NAME.setdefault(_r.name.lower(), []).append(_r)
# файлы-маркеры, по которым каталог считается корнем проекта
PROJECT_MARKERS = (
"package.json", "pyproject.toml", "setup.py", "requirements.txt", "Cargo.toml",
"go.mod", "pom.xml", "build.gradle", "build.gradle.kts", "composer.json", "Gemfile",
"pubspec.yaml", "CMakeLists.txt", "Makefile", ".git", "*.sln", "*.csproj",
"ProjectSettings", "*.uproject",
)
def _matches_marker(entries: set[str], markers: Iterable[str]) -> bool:
"""Есть ли рядом файл, подтверждающий тип проекта."""
for m in markers:
if "*" in m or "?" in m:
if any(fnmatch.fnmatch(e, m.lower()) for e in entries):
return True
elif m.lower() in entries:
return True
return False
def _has_inner(path: str, inner: Iterable[str]) -> bool:
"""Есть ли внутри каталога подтверждающий файл (pyvenv.cfg и т.п.)."""
try:
names = {e.name.lower() for e in os.scandir(path)}
except (OSError, ValueError):
return False
return any(m.lower() in names for m in inner)
def scan_projects(
roots: Iterable[str],
cancel: Cancel | None = None,
progress: Callable[[str], None] | None = None,
max_depth: int = 9,
min_size: int = 1024 * 1024,
include_hidden: bool = False,
) -> list[Finding]:
"""Обойти ``roots`` и найти node_modules, target, build, __pycache__ и т.п.
Найденный артефакт не раскрывается вглубь — его размер считается целиком,
а вложенные артефакты не дублируются в отчёте.
"""
findings: list[Finding] = []
seen: set[str] = set()
scanned = 0
for root in roots:
root = os.path.abspath(root)
if not os.path.isdir(root):
continue
stack: list[tuple[str, int]] = [(root, 0)]
while stack:
if cancel is not None and cancel.cancelled:
return findings
cur, depth = stack.pop()
if depth > max_depth:
continue
try:
entries = list(os.scandir(cur))
except (OSError, ValueError):
continue
scanned += 1
if progress is not None and scanned % 200 == 0:
progress(cur)
names = {e.name.lower() for e in entries}
subdirs: list[os.DirEntry] = []
for e in entries:
try:
if not e.is_dir(follow_symlinks=False):
continue
except OSError:
continue
subdirs.append(e)
for e in subdirs:
low = e.name.lower()
if low in SKIP_DIRS:
continue
if not include_hidden and low.startswith(".") and low not in _BY_NAME:
# скрытые каталоги без собственного правила пропускаем
continue
rules = _BY_NAME.get(low)
matched: ProjectRule | None = None
if rules:
for rule in rules:
if rule.marker and not _matches_marker(names, rule.marker):
continue
if rule.inner and not _has_inner(e.path, rule.inner):
continue
matched = rule
break
if matched is not None:
key = os.path.normcase(e.path)
if key in seen:
continue
seen.add(key)
size, nfiles, ndirs, mtime = dir_stats(e.path, cancel)
if size < min_size:
continue
project = os.path.basename(cur) or cur
findings.append(Finding(
path=e.path,
title=f"{matched.title} — {project}",
category=Category.DEV_PROJECT,
risk=matched.risk,
size=size, files=nfiles, dirs=ndirs, mtime=mtime,
detail=(f"{matched.detail}\nПроект: {cur}\n"
f"Экосистема: {matched.ecosystem or '—'} · "
f"{nfiles:,} файлов · {human(size)}".replace(",", " ")),
group=matched.title,
extra={"project": cur, "ecosystem": matched.ecosystem,
"artifact": matched.name},
))
if matched.stop:
continue # внутрь не идём
if low in NEVER_DESCEND:
continue # чужие зависимости — своих проектов внутри нет
stack.append((e.path, depth + 1))
findings.sort(key=lambda f: -f.size)
return findings
def find_project_roots(
roots: Iterable[str],
cancel: Cancel | None = None,
max_depth: int = 6,
) -> list[str]:
"""Быстро найти корни проектов — чтобы показать пользователю, где он программирует."""
found: list[str] = []
for root in roots:
root = os.path.abspath(root)
if not os.path.isdir(root):
continue
stack = [(root, 0)]
while stack:
if cancel is not None and cancel.cancelled:
return found
cur, depth = stack.pop()
if depth > max_depth:
continue
try:
entries = list(os.scandir(cur))
except (OSError, ValueError):
continue
names = {e.name.lower() for e in entries}
if _matches_marker(names, PROJECT_MARKERS):
found.append(cur)
continue # вложенные подпроекты не ищем
for e in entries:
try:
if e.is_dir(follow_symlinks=False) and e.name.lower() not in SKIP_DIRS \
and not e.name.startswith("."):
stack.append((e.path, depth + 1))
except OSError:
continue
return found
def summarize_by_ecosystem(findings: list[Finding]) -> dict[str, tuple[int, int]]:
"""{'JavaScript': (размер, количество)}"""
out: dict[str, tuple[int, int]] = {}
for f in findings:
eco = f.extra.get("ecosystem") or "Прочее"
size, cnt = out.get(eco, (0, 0))
out[eco] = (size + f.size, cnt + 1)
return dict(sorted(out.items(), key=lambda kv: -kv[1][0]))

220
sysdevcleaner/core/heavy.py Normal file
View File

@@ -0,0 +1,220 @@
"""Поиск крупных файлов и дубликатов."""
from __future__ import annotations
import hashlib
import os
from collections import defaultdict
from typing import Callable, Iterable
from .model import Category, Finding, Risk
from .rules import SKIP_DIRS
from .util import Cancel, human
# Расширения, которые почти всегда — «тяжёлый мусор, о котором забыли»
JUNKY_EXT = {
".iso": "образ диска", ".img": "образ диска", ".vhd": "виртуальный диск",
".vhdx": "виртуальный диск", ".vmdk": "виртуальный диск",
".zip": "архив", ".rar": "архив", ".7z": "архив", ".tar": "архив", ".gz": "архив",
".msi": "установщик", ".exe": "установщик/программа", ".dmg": "образ",
".bak": "резервная копия", ".old": "старая версия", ".tmp": "временный файл",
".dmp": "дамп памяти", ".log": "журнал", ".pkg": "пакет",
}
MEDIA_EXT = {".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".psd", ".ai", ".raw",
".cr2", ".nef", ".wav", ".flac", ".prproj", ".aep"}
def scan_big_files(
roots: Iterable[str],
min_size: int = 100 * 1024 * 1024,
limit: int = 400,
cancel: Cancel | None = None,
progress: Callable[[str], None] | None = None,
max_depth: int = 12,
) -> list[Finding]:
"""Найти файлы крупнее ``min_size``. Ничего не предлагает удалять автоматически."""
out: list[Finding] = []
scanned = 0
for root in roots:
root = os.path.abspath(root)
if not os.path.isdir(root):
continue
stack = [(root, 0)]
while stack:
if cancel is not None and cancel.cancelled:
return _trim(out, limit)
cur, depth = stack.pop()
if depth > max_depth:
continue
try:
entries = list(os.scandir(cur))
except (OSError, ValueError):
continue
scanned += 1
if progress is not None and scanned % 300 == 0:
progress(cur)
for e in entries:
try:
if e.is_dir(follow_symlinks=False):
if e.name.lower() not in SKIP_DIRS:
stack.append((e.path, depth + 1))
continue
if not e.is_file(follow_symlinks=False):
continue
st = e.stat(follow_symlinks=False)
except OSError:
continue
if st.st_size < min_size:
continue
ext = os.path.splitext(e.name)[1].lower()
kind = JUNKY_EXT.get(ext)
risk = Risk.MEDIUM if kind else Risk.HIGH
note = f"Тип: {kind}. " if kind else ""
if ext in MEDIA_EXT:
note = "Медиафайл или исходник проекта. "
out.append(Finding(
path=e.path,
title=e.name,
category=Category.BIG_FILES,
risk=risk,
size=st.st_size, files=1, mtime=st.st_mtime,
detail=(f"{note}{human(st.st_size)}\nПапка: {cur}\n"
"Решение об удалении принимаете вы — приложение ничего "
"не отмечает здесь автоматически."),
group=kind or "файл",
is_dir=False,
))
return _trim(out, limit)
def _trim(items: list[Finding], limit: int) -> list[Finding]:
items.sort(key=lambda f: -f.size)
return items[:limit]
# ------------------------------------------------------------- дубликаты ----
def _quick_hash(path: str, size: int, chunk: int = 65536) -> str | None:
"""Хеш по началу, середине и концу файла — быстрый предварительный фильтр."""
try:
h = hashlib.blake2b(digest_size=16)
with open(path, "rb") as fh:
h.update(fh.read(chunk))
if size > chunk * 3:
fh.seek(size // 2)
h.update(fh.read(chunk))
fh.seek(max(0, size - chunk))
h.update(fh.read(chunk))
h.update(str(size).encode())
return h.hexdigest()
except OSError:
return None
def _full_hash(path: str) -> str | None:
try:
h = hashlib.blake2b(digest_size=16)
with open(path, "rb") as fh:
for block in iter(lambda: fh.read(1 << 20), b""):
h.update(block)
return h.hexdigest()
except OSError:
return None
def scan_duplicates(
roots: Iterable[str],
min_size: int = 5 * 1024 * 1024,
cancel: Cancel | None = None,
progress: Callable[[str], None] | None = None,
max_files: int = 200_000,
verify_full: bool = True,
max_depth: int = 12,
) -> list[Finding]:
"""Найти одинаковые файлы: группировка по размеру → быстрый хеш → полный хеш."""
by_size: dict[int, list[str]] = defaultdict(list)
count = 0
for root in roots:
root = os.path.abspath(root)
if not os.path.isdir(root):
continue
stack = [(root, 0)]
while stack:
if cancel is not None and cancel.cancelled:
return []
cur, depth = stack.pop()
if depth > max_depth:
continue
try:
entries = list(os.scandir(cur))
except (OSError, ValueError):
continue
for e in entries:
try:
if e.is_dir(follow_symlinks=False):
if e.name.lower() not in SKIP_DIRS:
stack.append((e.path, depth + 1))
continue
if not e.is_file(follow_symlinks=False):
continue
st = e.stat(follow_symlinks=False)
except OSError:
continue
if st.st_size < min_size:
continue
by_size[st.st_size].append(e.path)
count += 1
if count >= max_files:
stack.clear()
break
if progress is not None and count % 500 < 2:
progress(cur)
groups: list[Finding] = []
for size, paths in by_size.items():
if len(paths) < 2:
continue
if cancel is not None and cancel.cancelled:
break
buckets: dict[str, list[str]] = defaultdict(list)
for p in paths:
qh = _quick_hash(p, size)
if qh:
buckets[qh].append(p)
for qh, group in buckets.items():
if len(group) < 2:
continue
if verify_full and size <= 512 * 1024 * 1024:
confirmed: dict[str, list[str]] = defaultdict(list)
for p in group:
fh = _full_hash(p)
if fh:
confirmed[fh].append(p)
final_groups = [g for g in confirmed.values() if len(g) > 1]
else:
final_groups = [group]
for g in final_groups:
g.sort(key=lambda p: (len(p), p))
wasted = size * (len(g) - 1)
try:
mtime = os.stat(g[0]).st_mtime
except OSError:
mtime = 0.0
groups.append(Finding(
path=g[1] if len(g) > 1 else g[0], # по умолчанию «лишняя» копия
title=f"{os.path.basename(g[0])} × {len(g)}",
category=Category.DUPLICATES,
risk=Risk.MEDIUM,
size=wasted, files=len(g) - 1, mtime=mtime,
detail=("Одинаковые по содержимому файлы ({} копий по {}).\n"
"Оригинал (оставить): {}\nЛишние копии:\n {}".format(
len(g), human(size), g[0], "\n ".join(g[1:]))),
group="дубликаты",
is_dir=False,
members=g[1:],
extra={"original": g[0], "all": g, "unit": size},
))
groups.sort(key=lambda f: -f.size)
return groups[:300]

172
sysdevcleaner/core/model.py Normal file
View File

@@ -0,0 +1,172 @@
"""Базовые структуры данных SysDevCleaner."""
from __future__ import annotations
import os
import time
from dataclasses import dataclass, field
from enum import IntEnum
from typing import Iterable
class Risk(IntEnum):
"""Насколько опасно удалять находку."""
SAFE = 0 # мусор в чистом виде, пересоздаётся сам
LOW = 1 # кеш: удаление вызовет лишь замедление первого запуска
MEDIUM = 2 # потеря состояния/настроек/сессий, нужна пересборка
HIGH = 3 # можно сломать проект или систему — только вручную
@property
def label(self) -> str:
return {
Risk.SAFE: "Безопасно",
Risk.LOW: "Низкий риск",
Risk.MEDIUM: "Средний риск",
Risk.HIGH: "Высокий риск",
}[self]
@property
def color(self) -> str:
return {
Risk.SAFE: "#2e9e5b",
Risk.LOW: "#4a90d9",
Risk.MEDIUM: "#d99a2b",
Risk.HIGH: "#d9534f",
}[self]
class Category(IntEnum):
WINDOWS = 0
BROWSERS = 1
DEV_CACHE = 2
DEV_PROJECT = 3
APPS = 4
LOGS = 5
PACKAGE_MANAGERS = 6
BIG_FILES = 7
DUPLICATES = 8
PROGRAMS = 9
@property
def label(self) -> str:
return {
Category.WINDOWS: "Система Windows",
Category.BROWSERS: "Браузеры",
Category.DEV_CACHE: "Кеши разработки",
Category.DEV_PROJECT: "Артефакты проектов",
Category.APPS: "Приложения",
Category.LOGS: "Логи и отчёты",
Category.PACKAGE_MANAGERS: "Пакетные менеджеры",
Category.BIG_FILES: "Крупные файлы",
Category.DUPLICATES: "Дубликаты",
Category.PROGRAMS: "Установленные программы",
}[self]
@property
def icon(self) -> str:
return {
Category.WINDOWS: "🪟",
Category.BROWSERS: "🌐",
Category.DEV_CACHE: "⚙",
Category.DEV_PROJECT: "📦",
Category.APPS: "🎮",
Category.LOGS: "📄",
Category.PACKAGE_MANAGERS: "🧰",
Category.BIG_FILES: "🐘",
Category.DUPLICATES: "👯",
Category.PROGRAMS: "🗂",
}[self]
@dataclass
class Finding:
"""Одна находка: путь (или группа путей), который можно удалить."""
path: str
title: str # человекочитаемое имя
category: Category
risk: Risk
size: int = 0 # байты
files: int = 0
dirs: int = 0
mtime: float = 0.0 # последнее изменение (unix)
detail: str = "" # подсказка что это и чем грозит удаление
group: str = "" # имя правила/группы для сводки
is_dir: bool = True
delete_contents_only: bool = False # чистим содержимое, сам каталог оставляем
members: list[str] = field(default_factory=list) # для дубликатов/мульти-путей
selected: bool = False
extra: dict = field(default_factory=dict)
@property
def age_days(self) -> float:
if not self.mtime:
return 0.0
return max(0.0, (time.time() - self.mtime) / 86400.0)
@property
def key(self) -> str:
return os.path.normcase(os.path.abspath(self.path))
@dataclass
class Program:
"""Установленная программа из реестра."""
name: str
publisher: str = ""
version: str = ""
install_date: str = ""
install_location: str = ""
uninstall_cmd: str = ""
registry_size: int = 0 # EstimatedSize из реестра
disk_size: int = 0 # реально посчитанный размер
last_used: float = 0.0 # mtime самого свежего файла
source: str = "" # HKLM / HKCU / WOW64
@property
def best_size(self) -> int:
return self.disk_size or self.registry_size
@property
def idle_days(self) -> float:
if not self.last_used:
return -1.0
return max(0.0, (time.time() - self.last_used) / 86400.0)
@dataclass
class Recommendation:
title: str
body: str
gain: int = 0 # сколько освободится, байт
severity: int = 1 # 0 инфо, 1 совет, 2 важно
findings: list[Finding] = field(default_factory=list)
@dataclass
class ScanResult:
findings: list[Finding] = field(default_factory=list)
programs: list[Program] = field(default_factory=list)
recommendations: list[Recommendation] = field(default_factory=list)
disks: list[dict] = field(default_factory=list)
errors: list[str] = field(default_factory=list)
started: float = 0.0
finished: float = 0.0
scanned_paths: int = 0
@property
def duration(self) -> float:
return max(0.0, (self.finished or time.time()) - self.started)
def total(self, cats: Iterable[Category] | None = None) -> int:
cs = set(cats) if cats else None
return sum(f.size for f in self.findings if cs is None or f.category in cs)
def by_category(self) -> dict[Category, list[Finding]]:
out: dict[Category, list[Finding]] = {}
for f in self.findings:
out.setdefault(f.category, []).append(f)
for lst in out.values():
lst.sort(key=lambda x: -x.size)
return out

View File

@@ -0,0 +1,185 @@
"""Инвентаризация установленных программ: размер на диске, давность использования."""
from __future__ import annotations
import os
import subprocess
import sys
from typing import Callable
from .model import Program
from .util import Cancel, dir_stats, known_paths
_UNINSTALL_KEYS = (
("HKLM", r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall", "HKLM"),
("HKLM", r"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall", "HKLM-32"),
("HKCU", r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall", "HKCU"),
)
def _read_registry() -> list[Program]:
if sys.platform != "win32":
return []
import winreg
roots = {"HKLM": winreg.HKEY_LOCAL_MACHINE, "HKCU": winreg.HKEY_CURRENT_USER}
out: list[Program] = []
seen: set[tuple[str, str]] = set()
for root_name, subkey, label in _UNINSTALL_KEYS:
try:
key = winreg.OpenKey(roots[root_name], subkey, 0, winreg.KEY_READ)
except OSError:
continue
with key:
try:
count = winreg.QueryInfoKey(key)[0]
except OSError:
continue
for i in range(count):
try:
name = winreg.EnumKey(key, i)
sub = winreg.OpenKey(key, name, 0, winreg.KEY_READ)
except OSError:
continue
with sub:
def val(field: str, default=""):
try:
return winreg.QueryValueEx(sub, field)[0]
except OSError:
return default
display = str(val("DisplayName") or "").strip()
if not display:
continue
if val("SystemComponent", 0) == 1:
continue
if val("ParentKeyName") or val("ParentDisplayName"):
# обновления и компоненты внутри бандлов
if not val("UninstallString"):
continue
version = str(val("DisplayVersion") or "")
dedup = (display.lower(), version)
if dedup in seen:
continue
seen.add(dedup)
try:
est = int(val("EstimatedSize", 0) or 0) * 1024
except (TypeError, ValueError):
est = 0
out.append(Program(
name=display,
publisher=str(val("Publisher") or ""),
version=version,
install_date=str(val("InstallDate") or ""),
install_location=str(val("InstallLocation") or "").strip('"'),
uninstall_cmd=str(val("QuietUninstallString")
or val("UninstallString") or ""),
registry_size=est,
source=label,
))
return out
def _guess_location(prog: Program) -> str:
"""Если InstallLocation пуст — пробуем вытащить путь из строки удаления."""
if prog.install_location and os.path.isdir(prog.install_location):
return prog.install_location
cmd = prog.uninstall_cmd
if not cmd:
return ""
candidate = ""
if cmd.startswith('"'):
candidate = cmd[1:].split('"', 1)[0]
else:
parts = cmd.split(" ")
for i in range(len(parts), 0, -1):
guess = " ".join(parts[:i])
if os.path.exists(guess):
candidate = guess
break
if not candidate:
return ""
folder = os.path.dirname(candidate)
k = known_paths()
# не отдаём общие корни вроде C:\Windows\System32
bad = {os.path.normcase(k["WINDIR"]), os.path.normcase(os.path.join(k["WINDIR"], "System32")),
os.path.normcase(k["PROGRAMFILES"]), os.path.normcase(k["PROGRAMFILES86"]),
os.path.normcase(k["TEMP"])}
if not folder or os.path.normcase(folder) in bad or len(folder) <= 3:
return ""
return folder if os.path.isdir(folder) else ""
def scan_programs(
measure_disk: bool = True,
cancel: Cancel | None = None,
progress: Callable[[str], None] | None = None,
) -> list[Program]:
"""Список установленных программ, отсортированный по занимаемому месту."""
progs = _read_registry()
if measure_disk:
for i, p in enumerate(progs):
if cancel is not None and cancel.cancelled:
break
loc = _guess_location(p)
if not loc:
continue
p.install_location = loc
size, _files, _dirs, mtime = dir_stats(loc, cancel, max_entries=60_000)
p.disk_size = size
p.last_used = mtime
if progress is not None and i % 10 == 0:
progress(p.name)
progs.sort(key=lambda p: -p.best_size)
return progs
def store_apps() -> list[Program]:
"""Приложения Microsoft Store (через PowerShell, может быть медленно)."""
if sys.platform != "win32":
return []
ps = (
"Get-AppxPackage | Select-Object Name,Publisher,Version,InstallLocation | "
"ConvertTo-Csv -NoTypeInformation"
)
try:
res = subprocess.run(
["powershell", "-NoProfile", "-NonInteractive", "-Command", ps],
capture_output=True, text=True, timeout=90, errors="ignore",
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
except Exception:
return []
import csv
import io
out: list[Program] = []
try:
for row in csv.DictReader(io.StringIO(res.stdout)):
loc = (row.get("InstallLocation") or "").strip()
if not loc or not os.path.isdir(loc):
continue
size, _f, _d, mtime = dir_stats(loc, max_entries=30_000)
out.append(Program(
name=row.get("Name") or "?",
publisher=(row.get("Publisher") or "").split(",")[0].replace("CN=", ""),
version=row.get("Version") or "",
install_location=loc,
disk_size=size,
last_used=mtime,
source="Store",
))
except Exception:
return out
out.sort(key=lambda p: -p.best_size)
return out
def uninstall(prog: Program) -> tuple[bool, str]:
"""Запустить штатный деинсталлятор программы (интерактивно)."""
if not prog.uninstall_cmd:
return False, "у программы нет команды удаления в реестре"
try:
subprocess.Popen(prog.uninstall_cmd, shell=True)
return True, ""
except Exception as e: # noqa: BLE001
return False, str(e)

645
sysdevcleaner/core/rules.py Normal file
View File

@@ -0,0 +1,645 @@
"""Каталог правил: где в Windows копится кеш, мусор и остатки разработки.
Пути записаны шаблонами с плейсхолдерами ``{LOCAL}``, ``{ROAMING}``, ``{HOME}``,
``{WINDIR}``, ``{PROGRAMDATA}``, ``{TEMP}``, ``{LOCALLOW}``, ``{PROGRAMFILES}``,
``{PROGRAMFILES86}``, ``{PUBLIC}``, ``{SYSTEMDRIVE}``.
Поддерживаются glob-маски (``*``), поэтому одно правило покрывает все профили
браузеров и все версии IDE сразу.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from .model import Category, Risk
@dataclass(frozen=True)
class Rule:
id: str
title: str
category: Category
risk: Risk
detail: str
paths: tuple[str, ...] = ()
contents_only: bool = False # чистим внутренности, каталог оставляем
needs_admin: bool = False
processes: tuple[str, ...] = () # предупредить, если запущены
min_size: int = 0 # не показывать мелочь
files_only: tuple[str, ...] = () # маски файлов внутри paths
C = Category
R = Risk
# --------------------------------------------------------------- Windows ----
WINDOWS_RULES: list[Rule] = [
Rule("win.temp.user", "Временные файлы пользователя", C.WINDOWS, R.SAFE,
"Рабочая свалка установщиков и приложений. Занятые файлы пропускаются автоматически.",
("{TEMP}",), contents_only=True),
Rule("win.temp.system", "Временные файлы системы", C.WINDOWS, R.SAFE,
"C:\\Windows\\Temp — то же самое, но для служб. Нужны права администратора.",
("{WINDIR}\\Temp",), contents_only=True, needs_admin=True),
Rule("win.update.download", "Кеш загрузок Windows Update", C.WINDOWS, R.LOW,
"Скачанные пакеты обновлений. Уже установленные не нужны; система скачает заново, если потребуется.",
("{WINDIR}\\SoftwareDistribution\\Download",), contents_only=True, needs_admin=True),
Rule("win.delivery", "Оптимизация доставки", C.WINDOWS, R.LOW,
"Кеш P2P-раздачи обновлений другим ПК в сети. Часто вырастает до нескольких ГБ.",
("{WINDIR}\\ServiceProfiles\\NetworkService\\AppData\\Local\\Microsoft\\Windows\\DeliveryOptimization",),
contents_only=True, needs_admin=True),
Rule("win.old", "Windows.old (прошлая версия Windows)", C.WINDOWS, R.MEDIUM,
"Полная копия старой системы после обновления. Удаление лишает возможности откатиться назад.",
("{SYSTEMDRIVE}Windows.old",), needs_admin=True),
Rule("win.prefetch", "Prefetch", C.WINDOWS, R.LOW,
"Данные предзагрузки приложений. После очистки первые запуски программ будут чуть медленнее.",
("{WINDIR}\\Prefetch",), contents_only=True, needs_admin=True),
Rule("win.crashdumps", "Дампы аварийных завершений", C.WINDOWS, R.SAFE,
"Снимки памяти упавших программ. Нужны только для разбора конкретного сбоя.",
("{LOCAL}\\CrashDumps", "{WINDIR}\\Minidump",), contents_only=True),
Rule("win.memorydmp", "Полный дамп памяти (MEMORY.DMP)", C.WINDOWS, R.LOW,
"Снимок всей оперативной памяти после BSOD — обычно равен объёму ОЗУ.",
("{WINDIR}\\MEMORY.DMP",), needs_admin=True),
Rule("win.wer", "Отчёты об ошибках (WER)", C.WINDOWS, R.SAFE,
"Очереди отчётов Windows Error Reporting.",
("{LOCAL}\\Microsoft\\Windows\\WER", "{PROGRAMDATA}\\Microsoft\\Windows\\WER"),
contents_only=True),
Rule("win.thumbcache", "Кеш эскизов и значков", C.WINDOWS, R.SAFE,
"thumbcache_*.db / iconcache_*.db. Пересоздаётся проводником на лету.",
("{LOCAL}\\Microsoft\\Windows\\Explorer",), contents_only=True,
files_only=("thumbcache_*.db", "iconcache_*.db")),
Rule("win.fontcache", "Кеш шрифтов", C.WINDOWS, R.LOW,
"Служебный кеш подсистемы шрифтов.",
("{WINDIR}\\ServiceProfiles\\LocalService\\AppData\\Local\\FontCache",),
contents_only=True, needs_admin=True),
Rule("win.inetcache", "Кеш Internet Explorer / WinINet", C.WINDOWS, R.SAFE,
"Общесистемный HTTP-кеш, которым пользуются и многие desktop-приложения.",
("{LOCAL}\\Microsoft\\Windows\\INetCache",), contents_only=True),
Rule("win.d3dcache", "Кеш шейдеров DirectX", C.WINDOWS, R.SAFE,
"Скомпилированные шейдеры. Пересобираются при следующем запуске игры.",
("{LOCAL}\\D3DSCache", "{LOCAL}\\Microsoft\\DirectX Shader Cache"), contents_only=True),
Rule("win.recycle", "Корзина", C.WINDOWS, R.MEDIUM,
"Файлы, удалённые ранее. После очистки восстановить их уже нельзя.",
("{SYSTEMDRIVE}$Recycle.Bin",), contents_only=True),
Rule("win.installer.patch", "Кеш патчей установщика Windows", C.WINDOWS, R.MEDIUM,
"$PatchCache$ нужен для отката и восстановления MSI-программ.",
("{WINDIR}\\Installer\\$PatchCache$",), contents_only=True, needs_admin=True),
Rule("win.panther", "Логи установки системы (Panther)", C.WINDOWS, R.SAFE,
"Отчёты установщика Windows и драйверов.",
("{WINDIR}\\Panther", "{WINDIR}\\inf\\setupapi.dev.log"),
contents_only=True, needs_admin=True),
Rule("win.defender.history", "История проверок Windows Defender", C.WINDOWS, R.LOW,
"Журнал сканирований антивируса. На саму защиту не влияет.",
("{PROGRAMDATA}\\Microsoft\\Windows Defender\\Scans\\History\\Service",
"{PROGRAMDATA}\\Microsoft\\Windows Defender\\Scans\\History\\Results"),
contents_only=True, needs_admin=True),
Rule("win.clipboard", "Кеш перьевого/облачного ввода", C.WINDOWS, R.LOW,
"Служебные данные InputPersonalization и истории буфера.",
("{LOCAL}\\Microsoft\\InputPersonalization\\TextHarvester",), contents_only=True),
Rule("win.livekernel", "LiveKernelReports", C.WINDOWS, R.SAFE,
"Дампы сбоев драйверов без полного BSOD.",
("{WINDIR}\\LiveKernelReports",), contents_only=True, needs_admin=True),
Rule("win.downloadedinstall", "Downloaded Installations", C.WINDOWS, R.LOW,
"Распакованные дистрибутивы старых установщиков.",
("{PROGRAMDATA}\\Downloaded Installations",), contents_only=True),
Rule("win.hyperv.cache", "Кеш Microsoft Store / AppX", C.WINDOWS, R.SAFE,
"Временные файлы доставки приложений из Store.",
("{LOCAL}\\Packages\\Microsoft.Windows.Search_cw5n1h2txyewy\\LocalState\\AppIconCache",
"{LOCAL}\\Microsoft\\Windows\\Caches"), contents_only=True),
]
# ------------------------------------------------------------- Логи ---------
LOG_RULES: list[Rule] = [
Rule("log.windows", "Системные журналы Windows", C.LOGS, R.LOW,
"C:\\Windows\\Logs — логи CBS, DISM, обслуживания компонентов.",
("{WINDIR}\\Logs\\CBS", "{WINDIR}\\Logs\\DISM", "{WINDIR}\\Logs\\MoSetup",
"{WINDIR}\\Logs\\WindowsUpdate"), contents_only=True, needs_admin=True),
Rule("log.perflogs", "PerfLogs", C.LOGS, R.SAFE,
"Отчёты монитора производительности.",
("{SYSTEMDRIVE}PerfLogs",), contents_only=True, needs_admin=True),
Rule("log.appdata", "Логи приложений в AppData", C.LOGS, R.SAFE,
"Каталоги logs/ внутри данных установленных программ.",
("{ROAMING}\\*\\logs", "{LOCAL}\\*\\logs", "{LOCAL}\\*\\log"),
contents_only=True, min_size=2 * 1024 * 1024),
Rule("log.npm", "Логи npm/yarn", C.LOGS, R.SAFE,
"_logs со stack trace неудачных установок пакетов.",
("{LOCAL}\\npm-cache\\_logs", "{ROAMING}\\npm-cache\\_logs"), contents_only=True),
Rule("log.nvidia", "Логи и кеш NVIDIA", C.LOGS, R.SAFE,
"Отчёты драйвера и телеметрии GeForce Experience.",
("{PROGRAMDATA}\\NVIDIA Corporation\\NvTelemetry",
"{LOCAL}\\NVIDIA Corporation\\NvTelemetry",
"{PROGRAMDATA}\\NVIDIA Corporation\\Downloader"), contents_only=True),
]
# ---------------------------------------------------------- Браузеры --------
_CHROMIUM = [
("Google Chrome", "{LOCAL}\\Google\\Chrome\\User Data", "chrome.exe"),
("Google Chrome Beta", "{LOCAL}\\Google\\Chrome Beta\\User Data", "chrome.exe"),
("Microsoft Edge", "{LOCAL}\\Microsoft\\Edge\\User Data", "msedge.exe"),
("Brave", "{LOCAL}\\BraveSoftware\\Brave-Browser\\User Data", "brave.exe"),
("Opera", "{ROAMING}\\Opera Software\\Opera Stable", "opera.exe"),
("Opera GX", "{ROAMING}\\Opera Software\\Opera GX Stable", "opera.exe"),
("Vivaldi", "{LOCAL}\\Vivaldi\\User Data", "vivaldi.exe"),
("Yandex", "{LOCAL}\\Yandex\\YandexBrowser\\User Data", "browser.exe"),
("Chromium", "{LOCAL}\\Chromium\\User Data", "chromium.exe"),
]
_CHROMIUM_CACHES = [
("Cache\\Cache_Data", "HTTP-кеш страниц", R.SAFE),
("Code Cache", "Скомпилированный JavaScript", R.SAFE),
("GPUCache", "Кеш GPU-шейдеров", R.SAFE),
("ShaderCache", "Кеш шейдеров", R.SAFE),
("GrShaderCache", "Кеш шейдеров Skia", R.SAFE),
("DawnCache", "Кеш WebGPU", R.SAFE),
("DawnGraphiteCache", "Кеш WebGPU (Graphite)", R.SAFE),
("DawnWebGPUCache", "Кеш WebGPU", R.SAFE),
("Service Worker\\CacheStorage", "Кеш service worker'ов сайтов", R.LOW),
("Service Worker\\ScriptCache", "Скрипты service worker'ов", R.LOW),
("Application Cache", "Устаревший AppCache", R.SAFE),
("Media Cache", "Кеш видео и аудио", R.SAFE),
("File System", "Sandboxed FileSystem API сайтов", R.MEDIUM),
("IndexedDB", "Локальные базы сайтов (офлайн-данные)", R.MEDIUM),
]
def _browser_rules() -> list[Rule]:
out: list[Rule] = []
for name, root, proc in _CHROMIUM:
for sub, desc, risk in _CHROMIUM_CACHES:
out.append(Rule(
f"br.{name.lower().replace(' ', '')}.{sub.split(chr(92))[0].lower().replace(' ', '')}",
f"{name} — {desc}",
C.BROWSERS, risk,
f"{desc}. Профили: все найденные. Браузер лучше закрыть перед очисткой.",
(f"{root}\\*\\{sub}", f"{root}\\{sub}"),
contents_only=True, processes=(proc,), min_size=256 * 1024,
))
out.append(Rule(
f"br.{name.lower().replace(' ', '')}.crashpad",
f"{name} — отчёты о падениях",
C.BROWSERS, R.SAFE, "Crashpad-дампы браузера.",
(f"{root}\\Crashpad\\reports", f"{root}\\Crashpad\\completed"),
contents_only=True, processes=(proc,),
))
out += [
Rule("br.firefox.cache", "Firefox — кеш", C.BROWSERS, R.SAFE,
"cache2 всех профилей Firefox.",
("{LOCAL}\\Mozilla\\Firefox\\Profiles\\*\\cache2",
"{LOCAL}\\Mozilla\\Firefox\\Profiles\\*\\startupCache",
"{LOCAL}\\Mozilla\\Firefox\\Profiles\\*\\shader-cache",
"{LOCAL}\\Mozilla\\Firefox\\Profiles\\*\\thumbnails"),
contents_only=True, processes=("firefox.exe",)),
Rule("br.firefox.crash", "Firefox — отчёты о падениях", C.BROWSERS, R.SAFE,
"Minidump'ы Mozilla Crash Reporter.",
("{ROAMING}\\Mozilla\\Firefox\\Crash Reports",), contents_only=True),
Rule("br.tor.cache", "Tor Browser — кеш", C.BROWSERS, R.SAFE,
"Кеш профиля Tor Browser.",
("{ROAMING}\\tor-browser\\*\\cache2",), contents_only=True),
]
return out
BROWSER_RULES = _browser_rules()
# ----------------------------------------------- Кеши инструментов разработки -
DEV_CACHE_RULES: list[Rule] = [
# --- JavaScript ---
Rule("dev.npm", "npm — глобальный кеш пакетов", C.DEV_CACHE, R.LOW,
"_cacache. npm скачает пакеты заново при следующей установке.",
("{LOCAL}\\npm-cache\\_cacache", "{ROAMING}\\npm-cache\\_cacache"), contents_only=True),
Rule("dev.yarn", "Yarn — кеш пакетов", C.DEV_CACHE, R.LOW,
"Глобальный кеш Yarn 1.x и Berry.",
("{LOCAL}\\Yarn\\Cache", "{LOCAL}\\Yarn\\Berry\\cache", "{HOME}\\.yarn\\berry\\cache"),
contents_only=True),
Rule("dev.pnpm", "pnpm — content-addressable store", C.DEV_CACHE, R.MEDIUM,
"Общее хранилище пакетов pnpm. После удаления все проекты потребуют pnpm install.",
("{LOCAL}\\pnpm\\store", "{LOCAL}\\pnpm-store", "{HOME}\\.pnpm-store"), contents_only=True),
Rule("dev.bun", "Bun — кеш установок", C.DEV_CACHE, R.LOW,
"Кеш менеджера пакетов Bun.",
("{LOCAL}\\bun\\install\\cache", "{HOME}\\.bun\\install\\cache"), contents_only=True),
Rule("dev.deno", "Deno — кеш модулей", C.DEV_CACHE, R.LOW,
"Скачанные удалённые модули и скомпилированный код.",
("{LOCAL}\\deno", "{HOME}\\.deno"), contents_only=True),
Rule("dev.node.gyp", "node-gyp — заголовки Node", C.DEV_CACHE, R.SAFE,
"Исходники Node для сборки нативных модулей.",
("{LOCAL}\\node-gyp\\Cache", "{HOME}\\.node-gyp"), contents_only=True),
Rule("dev.electron", "Electron / electron-builder — кеш", C.DEV_CACHE, R.LOW,
"Скачанные бинарники Electron и распакованные зависимости сборщика.",
("{LOCAL}\\electron\\Cache", "{LOCAL}\\electron-builder\\Cache",
"{LOCAL}\\electron-builder\\cache"), contents_only=True),
Rule("dev.puppeteer", "Puppeteer / Playwright — браузеры", C.DEV_CACHE, R.MEDIUM,
"Скачанные сборки Chromium/Firefox/WebKit для e2e-тестов. Обычно 1–3 ГБ.",
("{LOCAL}\\ms-playwright", "{HOME}\\.cache\\puppeteer", "{LOCAL}\\puppeteer"),
contents_only=True),
Rule("dev.cypress", "Cypress — кеш бинарников", C.DEV_CACHE, R.LOW,
"Каждая версия Cypress весит ~500 МБ и остаётся навсегда.",
("{LOCAL}\\Cypress\\Cache",), contents_only=True),
# --- Python ---
Rule("dev.pip", "pip — кеш колёс и загрузок", C.DEV_CACHE, R.SAFE,
"Скачанные wheel/sdist. Восстанавливается при следующей установке.",
("{LOCAL}\\pip\\Cache", "{HOME}\\.cache\\pip"), contents_only=True),
Rule("dev.poetry", "Poetry — кеш", C.DEV_CACHE, R.LOW,
"Артефакты и виртуальные окружения, созданные Poetry.",
("{LOCAL}\\pypoetry\\Cache\\cache", "{LOCAL}\\pypoetry\\Cache\\artifacts"),
contents_only=True),
Rule("dev.uv", "uv — кеш пакетов", C.DEV_CACHE, R.SAFE,
"Кеш быстрого установщика uv.",
("{LOCAL}\\uv\\cache", "{HOME}\\.cache\\uv"), contents_only=True),
Rule("dev.conda", "Conda — распакованные пакеты", C.DEV_CACHE, R.MEDIUM,
"pkgs/ занимает десятки ГБ. Безопаснее `conda clean -a`, но каталог можно чистить и вручную.",
("{HOME}\\anaconda3\\pkgs", "{HOME}\\miniconda3\\pkgs", "{HOME}\\.conda\\pkgs"),
contents_only=True),
Rule("dev.pyinstaller", "PyInstaller — кеш сборок", C.DEV_CACHE, R.SAFE,
"Промежуточные бинарники упаковщика.",
("{ROAMING}\\pyinstaller",), contents_only=True),
# --- .NET / Java / Rust / Go ---
Rule("dev.nuget", "NuGet — глобальные пакеты", C.DEV_CACHE, R.MEDIUM,
"~/.nuget/packages. Все .NET-проекты потребуют restore после очистки.",
("{HOME}\\.nuget\\packages",), contents_only=True),
Rule("dev.nuget.http", "NuGet — HTTP-кеш и temp", C.DEV_CACHE, R.SAFE,
"Промежуточный кеш скачивания, восстанавливается сам.",
("{LOCAL}\\NuGet\\v3-cache", "{LOCAL}\\NuGet\\plugins-cache", "{LOCAL}\\Temp\\NuGetScratch"),
contents_only=True),
Rule("dev.maven", "Maven — локальный репозиторий", C.DEV_CACHE, R.MEDIUM,
"~/.m2/repository. Часто десятки ГБ; при сборке скачается заново.",
("{HOME}\\.m2\\repository",), contents_only=True),
Rule("dev.gradle", "Gradle — кеши и дистрибутивы", C.DEV_CACHE, R.MEDIUM,
"caches/ и wrapper/dists — один из самых крупных пожирателей места у Android-разработчиков.",
("{HOME}\\.gradle\\caches", "{HOME}\\.gradle\\wrapper\\dists",
"{HOME}\\.gradle\\daemon", "{HOME}\\.gradle\\native"), contents_only=True),
Rule("dev.cargo", "Cargo — реестр и исходники крейтов", C.DEV_CACHE, R.LOW,
"registry/cache, registry/src и git/checkouts. Восстанавливаются при сборке.",
("{HOME}\\.cargo\\registry\\cache", "{HOME}\\.cargo\\registry\\src",
"{HOME}\\.cargo\\git\\checkouts", "{HOME}\\.cargo\\git\\db"), contents_only=True),
Rule("dev.gomod", "Go — кеш модулей и сборки", C.DEV_CACHE, R.LOW,
"GOMODCACHE/download и GOCACHE. Модули read-only — потребуется `go clean -modcache`, "
"если обычное удаление не пройдёт.",
("{HOME}\\go\\pkg\\mod\\cache\\download", "{LOCAL}\\go-build"), contents_only=True),
Rule("dev.sdkman", "Скачанные JDK / SDK", C.DEV_CACHE, R.MEDIUM,
"Архивы и распакованные SDK, которые обычно уже не используются.",
("{LOCAL}\\Temurin", "{HOME}\\.jdks", "{HOME}\\.sdkman\\archives"), contents_only=True),
# --- Mobile / Game / прочее ---
Rule("dev.android.sdk", "Android SDK — временные каталоги", C.DEV_CACHE, R.LOW,
"Кеш SDK-менеджера и загрузок.",
("{LOCAL}\\Android\\Sdk\\.temp", "{LOCAL}\\Android\\Sdk\\.downloadIntermediates",
"{HOME}\\.android\\cache", "{HOME}\\.android\\build-cache"), contents_only=True),
Rule("dev.android.emulator", "Образы эмулятора Android", C.DEV_CACHE, R.HIGH,
"system-images и созданные AVD. Каждое устройство — несколько ГБ. "
"Удаляйте только те, которыми точно не пользуетесь.",
("{LOCAL}\\Android\\Sdk\\system-images\\*", "{HOME}\\.android\\avd\\*"),
min_size=200 * 1024 * 1024),
Rule("dev.flutter", "Flutter / Dart — pub cache", C.DEV_CACHE, R.MEDIUM,
"Скачанные пакеты Dart и артефакты движка Flutter.",
("{LOCAL}\\Pub\\Cache\\hosted", "{LOCAL}\\Pub\\Cache\\git", "{HOME}\\.pub-cache\\hosted"),
contents_only=True),
Rule("dev.composer", "Composer — кеш PHP-пакетов", C.DEV_CACHE, R.SAFE,
"Скачанные zip-архивы и метаданные packagist.",
("{LOCAL}\\Composer\\files", "{LOCAL}\\Composer\\repo", "{ROAMING}\\Composer\\cache"),
contents_only=True),
Rule("dev.ruby", "RubyGems / Bundler — кеш", C.DEV_CACHE, R.LOW,
"Скачанные .gem-архивы.",
("{LOCAL}\\gem\\cache", "{HOME}\\.bundle\\cache", "{HOME}\\.gem\\cache"),
contents_only=True),
Rule("dev.terraform", "Terraform — кеш плагинов", C.DEV_CACHE, R.LOW,
"Скачанные провайдеры (каждый — сотни МБ).",
("{ROAMING}\\terraform.d\\plugin-cache", "{LOCAL}\\terraform.d"), contents_only=True),
Rule("dev.helm", "Helm / Kubernetes — кеш", C.DEV_CACHE, R.SAFE,
"Кеш репозиториев чартов.",
("{LOCAL}\\helm\\repository\\cache", "{LOCAL}\\Temp\\helm"), contents_only=True),
Rule("dev.vcpkg", "vcpkg / Conan — кеш сборок", C.DEV_CACHE, R.MEDIUM,
"Бинарные кеши C++-зависимостей. Пересборка может занять часы.",
("{LOCAL}\\vcpkg\\archives", "{LOCAL}\\vcpkg\\downloads", "{HOME}\\.conan\\data",
"{HOME}\\.conan2\\p"), contents_only=True),
Rule("dev.ccache", "ccache / sccache", C.DEV_CACHE, R.LOW,
"Кеш компилятора C/C++. Ускоряет пересборку, но безопасно удаляется.",
("{LOCAL}\\ccache", "{LOCAL}\\Mozilla\\sccache", "{HOME}\\.ccache"), contents_only=True),
Rule("dev.unity.global", "Unity — глобальные кеши", C.DEV_CACHE, R.MEDIUM,
"Asset Pipeline cache, кеш пакетов и загрузок Unity Hub.",
("{LOCAL}\\Unity\\cache", "{ROAMING}\\Unity\\cache", "{LOCAL}\\Unity\\Caches",
"{ROAMING}\\UnityHub\\Caches"), contents_only=True),
Rule("dev.unreal", "Unreal Engine — DDC", C.DEV_CACHE, R.MEDIUM,
"Derived Data Cache. Легко занимает 50+ ГБ; пересобирается долго.",
("{LOCAL}\\UnrealEngine\\Common\\DerivedDataCache",
"{LOCAL}\\UnrealEngine\\*\\DerivedDataCache"), contents_only=True),
Rule("dev.docker", "Docker Desktop — логи и кеш", C.DEV_CACHE, R.LOW,
"Логи и служебный кеш. Образы хранятся в WSL-диске — их чистят через `docker system prune`.",
("{LOCAL}\\Docker\\log", "{ROAMING}\\Docker\\log", "{ROAMING}\\Docker Desktop\\logs"),
contents_only=True),
]
# ------------------------------------------------------------ IDE / редакторы -
IDE_RULES: list[Rule] = [
Rule("ide.vscode", "VS Code — кеш и временные данные", C.DEV_CACHE, R.LOW,
"Cache, CachedData, GPUCache, логи. Настройки и расширения не затрагиваются.",
("{ROAMING}\\Code\\Cache", "{ROAMING}\\Code\\CachedData",
"{ROAMING}\\Code\\CachedExtensionVSIXs", "{ROAMING}\\Code\\Code Cache",
"{ROAMING}\\Code\\GPUCache", "{ROAMING}\\Code\\logs",
"{ROAMING}\\Code\\User\\workspaceStorage"),
contents_only=True, processes=("code.exe",)),
Rule("ide.vscode.insiders", "VS Code Insiders / VSCodium — кеш", C.DEV_CACHE, R.LOW,
"То же самое для альтернативных сборок редактора.",
("{ROAMING}\\Code - Insiders\\Cache", "{ROAMING}\\Code - Insiders\\CachedData",
"{ROAMING}\\VSCodium\\Cache", "{ROAMING}\\VSCodium\\CachedData"), contents_only=True),
Rule("ide.cursor", "Cursor / Windsurf — кеш", C.DEV_CACHE, R.LOW,
"Кеши AI-редакторов на базе VS Code.",
("{ROAMING}\\Cursor\\Cache", "{ROAMING}\\Cursor\\CachedData",
"{ROAMING}\\Cursor\\Code Cache", "{ROAMING}\\Cursor\\GPUCache",
"{ROAMING}\\Windsurf\\Cache", "{ROAMING}\\Windsurf\\CachedData"),
contents_only=True, processes=("cursor.exe",)),
Rule("ide.jetbrains.caches", "JetBrains — индексы и кеши", C.DEV_CACHE, R.LOW,
"Индексы IDEA/PyCharm/WebStorm и др. Перестраиваются при открытии проекта.",
("{LOCAL}\\JetBrains\\*\\caches", "{LOCAL}\\JetBrains\\*\\index",
"{LOCAL}\\JetBrains\\*\\tmp", "{LOCAL}\\JetBrains\\*\\log"),
contents_only=True, processes=("idea64.exe", "pycharm64.exe", "webstorm64.exe")),
Rule("ide.jetbrains.old", "JetBrains — данные старых версий IDE", C.DEV_CACHE, R.MEDIUM,
"Конфигурация и кеши версий, которые давно не запускались.",
("{ROAMING}\\JetBrains\\*",), min_size=100 * 1024 * 1024),
Rule("ide.visualstudio", "Visual Studio — кеши", C.DEV_CACHE, R.LOW,
"ComponentModelCache, кеш телеметрии и отладчика.",
("{LOCAL}\\Microsoft\\VisualStudio\\*\\ComponentModelCache",
"{LOCAL}\\Microsoft\\VisualStudio\\*\\Designer\\ShadowCache",
"{LOCAL}\\Microsoft\\VisualStudio\\Packages\\_Instances",
"{LOCAL}\\Microsoft\\VSCommon\\*\\SQM"),
contents_only=True, processes=("devenv.exe",)),
Rule("ide.vs.installer", "Visual Studio Installer — кеш дистрибутивов", C.DEV_CACHE, R.MEDIUM,
"Скачанные пакеты установщика VS. Часто 10–20 ГБ, нужны только при изменении состава.",
("{PROGRAMDATA}\\Microsoft\\VisualStudio\\Packages",), contents_only=True,
needs_admin=True, min_size=200 * 1024 * 1024),
Rule("ide.vs.backup", "Visual Studio — автосохранения и трассировки", C.DEV_CACHE, R.LOW,
"Backup Files и ActivityLog.",
("{LOCAL}\\Microsoft\\VisualStudio\\*\\ActivityLog.xml",
"{HOME}\\Documents\\Visual Studio *\\Backup Files"), contents_only=True),
Rule("ide.xamarin", "Xamarin / .NET Android — кеш", C.DEV_CACHE, R.LOW,
"Скачанные компоненты Xamarin.",
("{LOCAL}\\Xamarin\\zips", "{LOCAL}\\Xamarin\\Cache"), contents_only=True),
Rule("ide.eclipse", "Eclipse / NetBeans — кеш", C.DEV_CACHE, R.LOW,
"Метаданные рабочих областей и индексы.",
("{ROAMING}\\NetBeans\\*\\var\\cache", "{HOME}\\.eclipse\\*\\configuration\\org.eclipse.osgi"),
contents_only=True),
]
# ------------------------------------------------------------ Приложения ----
APP_RULES: list[Rule] = [
Rule("app.discord", "Discord — кеш", C.APPS, R.SAFE,
"Cache, Code Cache, GPUCache клиента Discord.",
("{ROAMING}\\discord\\Cache", "{ROAMING}\\discord\\Code Cache",
"{ROAMING}\\discord\\GPUCache", "{ROAMING}\\discordptb\\Cache",
"{ROAMING}\\discordcanary\\Cache"),
contents_only=True, processes=("discord.exe",)),
Rule("app.slack", "Slack — кеш", C.APPS, R.SAFE,
"Кеш сообщений и вложений Slack.",
("{ROAMING}\\Slack\\Cache", "{ROAMING}\\Slack\\Code Cache",
"{ROAMING}\\Slack\\GPUCache", "{ROAMING}\\Slack\\Service Worker\\CacheStorage"),
contents_only=True, processes=("slack.exe",)),
Rule("app.teams", "Microsoft Teams — кеш", C.APPS, R.LOW,
"Кеш классического и нового клиента Teams.",
("{ROAMING}\\Microsoft\\Teams\\Cache",
"{ROAMING}\\Microsoft\\Teams\\Code Cache",
"{ROAMING}\\Microsoft\\Teams\\GPUCache",
"{ROAMING}\\Microsoft\\Teams\\Service Worker\\CacheStorage",
"{LOCAL}\\Packages\\MSTeams_*\\LocalCache"),
contents_only=True, processes=("ms-teams.exe", "teams.exe")),
Rule("app.telegram", "Telegram Desktop — кеш медиа", C.APPS, R.LOW,
"Скачанные фото и видео. При необходимости подтянутся с сервера заново.",
("{ROAMING}\\Telegram Desktop\\tdata\\user_data\\cache",
"{ROAMING}\\Telegram Desktop\\tdata\\user_data\\media_cache",
"{ROAMING}\\Telegram Desktop\\tdata\\emoji"),
contents_only=True, processes=("telegram.exe",)),
Rule("app.spotify", "Spotify — кеш треков", C.APPS, R.SAFE,
"Локальный кеш прослушанной музыки.",
("{LOCAL}\\Spotify\\Data", "{LOCAL}\\Spotify\\Storage",
"{ROAMING}\\Spotify\\Data"), contents_only=True, processes=("spotify.exe",)),
Rule("app.steam", "Steam — кеш и логи", C.APPS, R.LOW,
"htmlcache, shadercache, логи и незавершённые загрузки.",
("{LOCAL}\\Steam\\htmlcache", "{PROGRAMFILES86}\\Steam\\logs",
"{PROGRAMFILES86}\\Steam\\appcache\\httpcache",
"{PROGRAMFILES86}\\Steam\\steamapps\\shadercache",
"{PROGRAMFILES86}\\Steam\\steamapps\\temp",
"{PROGRAMFILES86}\\Steam\\steamapps\\downloading"),
contents_only=True, processes=("steam.exe",)),
Rule("app.epic", "Epic Games — webcache", C.APPS, R.SAFE,
"Кеш встроенного браузера лаунчера.",
("{LOCAL}\\EpicGamesLauncher\\Saved\\webcache",
"{LOCAL}\\EpicGamesLauncher\\Saved\\webcache_*",
"{LOCAL}\\EpicGamesLauncher\\Saved\\Logs"), contents_only=True),
Rule("app.battlenet", "Battle.net — кеш", C.APPS, R.SAFE,
"Кеш агента обновлений Blizzard.",
("{ROAMING}\\Battle.net\\Cache", "{PROGRAMDATA}\\Battle.net\\Agent\\data"),
contents_only=True),
Rule("app.origin", "EA / Origin — кеш", C.APPS, R.SAFE,
"Кеш лаунчеров EA.",
("{ROAMING}\\Origin\\Cache", "{LOCAL}\\Origin", "{LOCAL}\\Electronic Arts\\EA Desktop\\cache"),
contents_only=True),
Rule("app.adobe", "Adobe — медиакеш", C.APPS, R.LOW,
"Media Cache Files Premiere/After Effects. Пересоздаётся при открытии проекта, "
"но первый предпросмотр будет медленнее.",
("{ROAMING}\\Adobe\\Common\\Media Cache Files",
"{ROAMING}\\Adobe\\Common\\Media Cache",
"{ROAMING}\\Adobe\\Common\\Peak Files",
"{LOCAL}\\Adobe\\*\\Cache"), contents_only=True),
Rule("app.obs", "OBS Studio — логи и профили сбоев", C.APPS, R.SAFE,
"Логи трансляций и crash-репорты.",
("{ROAMING}\\obs-studio\\logs", "{ROAMING}\\obs-studio\\crashes"), contents_only=True),
Rule("app.zoom", "Zoom — данные и логи", C.APPS, R.LOW,
"Кеш конференций и логи клиента.",
("{ROAMING}\\Zoom\\logs", "{ROAMING}\\Zoom\\data"), contents_only=True),
Rule("app.office", "Microsoft Office — кеш документов", C.APPS, R.LOW,
"Кеш облачных документов Office. Несинхронизированные правки будут потеряны.",
("{LOCAL}\\Microsoft\\Office\\*\\OfficeFileCache",
"{LOCAL}\\Microsoft\\Office\\*\\WebServiceCache"), contents_only=True,
processes=("winword.exe", "excel.exe")),
Rule("app.onedrive", "OneDrive — логи", C.APPS, R.SAFE,
"Телеметрия и логи синхронизации.",
("{LOCAL}\\Microsoft\\OneDrive\\logs",), contents_only=True),
Rule("app.nvidia.cache", "NVIDIA — кеш шейдеров", C.APPS, R.SAFE,
"DXCache / GLCache / NV_Cache. Пересобирается играми автоматически.",
("{LOCAL}\\NVIDIA\\DXCache", "{LOCAL}\\NVIDIA\\GLCache",
"{LOCAL}\\NVIDIA Corporation\\NV_Cache", "{LOCAL}\\NVIDIA\\ComputeCache"),
contents_only=True),
Rule("app.amd.cache", "AMD — кеш шейдеров", C.APPS, R.SAFE,
"DxCache и GLCache драйвера AMD.",
("{LOCAL}\\AMD\\DxCache", "{LOCAL}\\AMD\\GLCache", "{LOCAL}\\AMD\\DxcCache"),
contents_only=True),
Rule("app.electron.generic", "Прочие Electron-приложения — кеш", C.APPS, R.LOW,
"Автоматически найденные каталоги Cache/GPUCache в данных установленных приложений.",
("{ROAMING}\\*\\Cache\\Cache_Data", "{ROAMING}\\*\\GPUCache",
"{ROAMING}\\*\\Code Cache\\js", "{LOCAL}\\*\\GPUCache"),
contents_only=True, min_size=10 * 1024 * 1024),
]
# ------------------------------------------------------ Пакетные менеджеры --
PKG_RULES: list[Rule] = [
Rule("pkg.chocolatey", "Chocolatey — кеш загрузок", C.PACKAGE_MANAGERS, R.SAFE,
"Скачанные установщики пакетов choco.",
("{LOCAL}\\Temp\\chocolatey", "{PROGRAMDATA}\\chocolatey\\lib-bkp"), contents_only=True),
Rule("pkg.scoop", "Scoop — кеш и старые версии", C.PACKAGE_MANAGERS, R.LOW,
"cache/ с архивами и apps/*/старые версии приложений.",
("{HOME}\\scoop\\cache", "{PROGRAMDATA}\\scoop\\cache"), contents_only=True),
Rule("pkg.winget", "winget — кеш установщиков", C.PACKAGE_MANAGERS, R.SAFE,
"Временные файлы Windows Package Manager.",
("{LOCAL}\\Packages\\Microsoft.DesktopAppInstaller_*\\LocalState\\TempState",
"{LOCAL}\\Temp\\WinGet"), contents_only=True),
Rule("pkg.msi.cache", "Кеш загруженных установщиков", C.PACKAGE_MANAGERS, R.LOW,
"Package Cache — дистрибутивы Visual C++, .NET и подобных бандлов.",
("{PROGRAMDATA}\\Package Cache",), contents_only=True, needs_admin=True,
min_size=100 * 1024 * 1024),
]
# ------------------------------------------- Артефакты внутри проектов ------
#
# Их ищет devscan.py: рекурсивный обход выбранных каталогов.
@dataclass(frozen=True)
class ProjectRule:
name: str # имя каталога/файла-артефакта
title: str
risk: Risk
detail: str
marker: tuple[str, ...] = () # файлы рядом, подтверждающие тип проекта
inner: tuple[str, ...] = () # файлы внутри каталога, подтверждающие находку
stop: bool = True # не заходить внутрь найденного каталога
ecosystem: str = ""
PROJECT_RULES: tuple[ProjectRule, ...] = (
ProjectRule("node_modules", "node_modules", R.LOW,
"Зависимости Node. Восстанавливаются командой npm/yarn/pnpm install.",
("package.json",), stop=True, ecosystem="JavaScript"),
ProjectRule(".next", "Сборка Next.js", R.SAFE,
"Кеш и артефакты сборки Next.js.", ("package.json",), ecosystem="JavaScript"),
ProjectRule(".nuxt", "Сборка Nuxt", R.SAFE, "Артефакты Nuxt.", ("package.json",),
ecosystem="JavaScript"),
ProjectRule(".output", "Вывод Nitro/Nuxt", R.SAFE, "Собранный сервер Nitro.",
("package.json",), ecosystem="JavaScript"),
ProjectRule(".turbo", "Кеш Turborepo", R.SAFE, "Кеш задач Turborepo.", ("package.json",),
ecosystem="JavaScript"),
ProjectRule(".parcel-cache", "Кеш Parcel", R.SAFE, "Кеш бандлера Parcel.", ("package.json",),
ecosystem="JavaScript"),
ProjectRule(".angular", "Кеш Angular CLI", R.SAFE, "Кеш сборки Angular.", ("angular.json",),
ecosystem="JavaScript"),
ProjectRule(".svelte-kit", "Сборка SvelteKit", R.SAFE, "Артефакты SvelteKit.",
("package.json",), ecosystem="JavaScript"),
ProjectRule(".astro", "Кеш Astro", R.SAFE, "Служебные типы и кеш Astro.", ("package.json",),
ecosystem="JavaScript"),
ProjectRule(".docusaurus", "Кеш Docusaurus", R.SAFE, "Кеш генератора документации.",
("package.json",), ecosystem="JavaScript"),
ProjectRule("storybook-static", "Сборка Storybook", R.SAFE, "Статическая сборка Storybook.",
("package.json",), ecosystem="JavaScript"),
ProjectRule("coverage", "Отчёты о покрытии", R.SAFE, "Результаты прогона тестов.",
(), ecosystem="Тесты"),
ProjectRule(".nyc_output", "Сырые данные покрытия", R.SAFE, "Промежуточные данные nyc.",
(), ecosystem="Тесты"),
ProjectRule("__pycache__", "Байт-код Python", R.SAFE,
"Скомпилированные .pyc. Пересоздаются при запуске.", (), ecosystem="Python"),
ProjectRule(".pytest_cache", "Кеш pytest", R.SAFE, "Кеш последнего прогона тестов.",
(), ecosystem="Python"),
ProjectRule(".mypy_cache", "Кеш mypy", R.SAFE, "Инкрементальный кеш проверки типов.",
(), ecosystem="Python"),
ProjectRule(".ruff_cache", "Кеш ruff", R.SAFE, "Кеш линтера ruff.", (), ecosystem="Python"),
ProjectRule(".tox", "Окружения tox", R.LOW, "Виртуальные окружения матрицы тестов.",
(), ecosystem="Python"),
ProjectRule(".venv", "Виртуальное окружение", R.MEDIUM,
"Пересоздаётся из requirements.txt / pyproject.toml, но не мгновенно.",
(), inner=("pyvenv.cfg",), ecosystem="Python"),
ProjectRule("venv", "Виртуальное окружение", R.MEDIUM,
"Пересоздаётся из requirements.txt / pyproject.toml.",
(), inner=("pyvenv.cfg",), ecosystem="Python"),
ProjectRule("env", "Виртуальное окружение", R.MEDIUM,
"Пересоздаётся из requirements.txt / pyproject.toml.",
(), inner=("pyvenv.cfg",), ecosystem="Python"),
ProjectRule(".conda", "Окружение conda в проекте", R.MEDIUM,
"Локальное окружение conda.", (), inner=("conda-meta",), ecosystem="Python"),
ProjectRule(".ipynb_checkpoints", "Чекпоинты Jupyter", R.SAFE, "Автосохранения ноутбуков.",
(), ecosystem="Python"),
ProjectRule("target", "Сборка Rust / Maven", R.LOW,
"Артефакты cargo build или Maven. Пересборка может занять минуты.",
("Cargo.toml", "pom.xml"), ecosystem="Rust/Java"),
ProjectRule("bin", "Сборка .NET (bin)", R.LOW, "Выходные файлы сборки .NET.",
("*.csproj", "*.sln", "*.fsproj", "*.vbproj"), ecosystem=".NET"),
ProjectRule("obj", "Промежуточные файлы .NET", R.SAFE, "Объектные файлы сборки .NET.",
("*.csproj", "*.sln", "*.fsproj", "*.vbproj"), ecosystem=".NET"),
ProjectRule(".vs", "Служебный каталог Visual Studio", R.SAFE,
"Кеш IntelliSense и состояние решения.", (), ecosystem=".NET"),
ProjectRule("build", "Каталог сборки", R.LOW,
"Выход CMake/Gradle/Flutter. Убедитесь, что это не исходники.",
("CMakeLists.txt", "build.gradle", "build.gradle.kts", "pubspec.yaml",
"package.json", "setup.py", "pyproject.toml"), ecosystem="Сборка"),
ProjectRule("cmake-build-debug", "Сборка CMake (Debug)", R.SAFE, "Каталог сборки CLion.",
(), ecosystem="C/C++"),
ProjectRule("cmake-build-release", "Сборка CMake (Release)", R.SAFE, "Каталог сборки CLion.",
(), ecosystem="C/C++"),
ProjectRule("CMakeFiles", "Служебные файлы CMake", R.SAFE, "Промежуточные файлы генератора.",
(), ecosystem="C/C++"),
ProjectRule(".gradle", "Кеш Gradle в проекте", R.SAFE, "Локальный кеш задач Gradle.",
(), ecosystem="Java/Android"),
ProjectRule(".dart_tool", "Служебные файлы Dart", R.SAFE, "Кеш пакетов и сборки Dart.",
("pubspec.yaml",), ecosystem="Flutter"),
ProjectRule("Pods", "CocoaPods", R.LOW, "Зависимости iOS-проекта.", (), ecosystem="Mobile"),
ProjectRule("vendor", "Зависимости Composer/Go/Bundler", R.LOW,
"Восстанавливается composer install / bundle install.",
("composer.json", "Gemfile", "go.mod"), ecosystem="PHP/Ruby/Go"),
ProjectRule("Library", "Кеш проекта Unity", R.LOW,
"Импортированные ассеты Unity. Перегенерируется при открытии проекта (долго).",
("Assets", "ProjectSettings"), inner=("ArtifactDB", "ScriptAssemblies",
"PackageCache"), ecosystem="Unity"),
ProjectRule("Intermediate", "Промежуточные файлы Unreal", R.SAFE,
"Артефакты компиляции Unreal Engine.", (), ecosystem="Unreal"),
ProjectRule("DerivedDataCache", "DDC проекта Unreal", R.LOW,
"Производные данные ассетов Unreal.", (), ecosystem="Unreal"),
ProjectRule(".terraform", "Плагины Terraform в проекте", R.LOW,
"Скачанные провайдеры. Восстанавливается terraform init.", (), ecosystem="DevOps"),
ProjectRule(".serverless", "Артефакты Serverless Framework", R.SAFE,
"Сгенерированные пакеты деплоя.", (), ecosystem="DevOps"),
ProjectRule(".cache", "Обобщённый кеш инструментов", R.SAFE,
"Кеш сборщиков и линтеров внутри проекта.", (), ecosystem="Сборка"),
ProjectRule("dist", "Каталог сборки dist", R.LOW,
"Собранные файлы. Убедитесь, что это не исходники, которые вы коммитите.",
("package.json", "setup.py", "pyproject.toml", "rollup.config.js", "vite.config.js",
"vite.config.ts"), ecosystem="Сборка"),
ProjectRule("out", "Каталог сборки out", R.LOW, "Выход сборщика.",
("package.json", "tsconfig.json"), ecosystem="Сборка"),
)
# Файлы-артефакты (не каталоги), которые ищем в проектах
PROJECT_FILES: tuple[tuple[str, str, Risk, str], ...] = (
("*.pyc", "Скомпилированный Python вне __pycache__", R.SAFE, "Python"),
("npm-debug.log*", "Лог сбоя npm", R.SAFE, "JavaScript"),
("yarn-error.log", "Лог сбоя yarn", R.SAFE, "JavaScript"),
(".eslintcache", "Кеш ESLint", R.SAFE, "JavaScript"),
("tsconfig.tsbuildinfo", "Инкрементальный кеш TypeScript", R.SAFE, "JavaScript"),
("*.stackdump", "Дамп аварийного завершения", R.SAFE, "Прочее"),
("Thumbs.db", "Кеш эскизов Windows", R.SAFE, "Прочее"),
("desktop.ini", "", R.HIGH, ""), # намеренно не предлагаем
)
# Каталоги, внутрь которых не спускаемся при поиске артефактов, даже если сам
# каталог не подошёл ни под одно правило: внутри всё равно нет «своих» проектов.
NEVER_DESCEND = {
"node_modules", "__pycache__", ".git", ".svn", ".hg", "site-packages",
"dist-packages", ".venv", "venv", ".tox", ".mypy_cache", ".pytest_cache",
".ruff_cache", ".gradle", ".vs", ".idea", "pods", "bower_components",
}
# Каталоги, в которые никогда не заходим при рекурсивном поиске
SKIP_DIRS = {
"$recycle.bin", "system volume information", "windows", "winsxs", "program files",
"program files (x86)", "programdata", "$windows.~bt", "$windows.~ws", "appdata",
"onedrive", "recovery", "boot", "perflogs", "msocache", "config.msi",
}
def all_rules() -> list[Rule]:
return (WINDOWS_RULES + LOG_RULES + BROWSER_RULES + DEV_CACHE_RULES
+ IDE_RULES + APP_RULES + PKG_RULES)
def rules_for(categories: set[Category]) -> list[Rule]:
return [r for r in all_rules() if r.category in categories]

View File

@@ -0,0 +1,328 @@
"""Движок сканирования: раскрывает правила в конкретные находки."""
from __future__ import annotations
import fnmatch
import glob
import os
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass, field
from typing import Callable
from . import devscan, heavy, programs as progmod
from .advisor import build_recommendations
from .model import Category, Finding, Risk, ScanResult
from .rules import Rule, all_rules
from .util import (Cancel, disk_usage, drives, human, is_protected, known_paths,
running_processes)
Progress = Callable[[str, float], None] # (сообщение, доля 0..1)
@dataclass
class ScanOptions:
categories: set[Category] = field(default_factory=lambda: {
Category.WINDOWS, Category.BROWSERS, Category.DEV_CACHE, Category.DEV_PROJECT,
Category.APPS, Category.LOGS, Category.PACKAGE_MANAGERS,
})
project_roots: list[str] = field(default_factory=list)
scan_big_files: bool = False
big_file_min: int = 200 * 1024 * 1024
scan_duplicates: bool = False
duplicate_min: int = 10 * 1024 * 1024
scan_programs: bool = True
measure_program_size: bool = True
min_finding_size: int = 64 * 1024
workers: int = 12
project_min_size: int = 1024 * 1024
drives: set[str] = field(default_factory=set)
"""Ограничение по дискам, например ``{"C:", "D:"}``. Пусто — искать везде.
Фильтр применяется и к правилам путей, и к поиску проектов, крупных файлов
и дубликатов, поэтому «просканировать только D:» действительно не трогает C:.
"""
def drive_of(path: str) -> str:
"""'D:\\Desktop\\x' -> 'D:'. Для UNC-путей вернёт сам сетевой корень."""
return os.path.splitdrive(os.path.abspath(path))[0].upper()
def normalize_drives(values) -> set[str]:
"""Привести 'd', 'D:', 'D:\\' к единому виду 'D:'."""
out: set[str] = set()
for v in values or ():
v = str(v).strip().upper().rstrip("\\/")
if not v:
continue
if not v.endswith(":"):
v += ":"
out.add(v)
return out
def on_selected_drive(path: str, drives: set[str]) -> bool:
return not drives or drive_of(path) in drives
def roots_for(opts: "ScanOptions") -> list[str]:
"""Где искать проекты с учётом выбранных дисков."""
roots = opts.project_roots or default_project_roots()
if not opts.drives:
return roots
kept = [r for r in roots if drive_of(r) in opts.drives]
# диск выбран, но ни одной папки на нём не настроено — берём диск целиком
for d in sorted(opts.drives):
if any(drive_of(r) == d for r in kept):
continue
root = d + "\\"
if os.path.isdir(root):
kept.append(root)
return kept
def default_project_roots() -> list[str]:
"""Разумные места, где обычно лежат проекты."""
k = known_paths()
home = k["HOME"]
candidates = [
os.path.join(home, "Documents"), os.path.join(home, "Desktop"),
os.path.join(home, "source"), os.path.join(home, "src"),
os.path.join(home, "Projects"), os.path.join(home, "projects"),
os.path.join(home, "dev"), os.path.join(home, "Dev"),
os.path.join(home, "repos"), os.path.join(home, "git"),
os.path.join(home, "code"), os.path.join(home, "work"),
]
roots = [p for p in candidates if os.path.isdir(p)]
for d in drives():
if os.path.normcase(d) == os.path.normcase(k["SYSTEMDRIVE"]):
continue
roots.append(d) # несистемные диски целиком
# убираем вложенные дубликаты
uniq: list[str] = []
for r in sorted(set(roots), key=len):
n = os.path.normcase(os.path.abspath(r))
if any(n.startswith(os.path.normcase(os.path.abspath(u)) + os.sep) for u in uniq):
continue
uniq.append(r)
return uniq
# ------------------------------------------------------- раскрытие правил ---
def _expand(pattern: str, kp: dict[str, str]) -> list[str]:
try:
raw = pattern.format(**kp)
except (KeyError, IndexError, ValueError):
return []
if any(ch in raw for ch in "*?["):
try:
return glob.glob(raw)
except OSError:
return []
return [raw] if os.path.exists(raw) else []
def _measure(rule: Rule, path: str, cancel: Cancel, procs: set[str]) -> Finding | None:
from .util import dir_stats, path_size
if not os.path.exists(path):
return None
if rule.contents_only and is_protected(path):
# чистим только содержимое — сам защищённый каталог не трогаем, это допустимо
pass
elif is_protected(path):
return None
if rule.files_only:
total = 0
newest = 0.0
count = 0
try:
for e in os.scandir(path):
try:
if not e.is_file(follow_symlinks=False):
continue
if not any(fnmatch.fnmatch(e.name.lower(), m.lower())
for m in rule.files_only):
continue
st = e.stat(follow_symlinks=False)
except OSError:
continue
total += st.st_size
count += 1
newest = max(newest, st.st_mtime)
except OSError:
return None
size, files, dirs, mtime = total, count, 0, newest
elif os.path.isdir(path):
size, files, dirs, mtime = dir_stats(path, cancel)
else:
size, files, dirs, mtime = path_size(path, cancel)
if size < max(rule.min_size, 1):
return None
busy = [p for p in rule.processes if p.lower() in procs]
detail = rule.detail
if busy:
detail += ("\n\n⚠ Сейчас запущено: " + ", ".join(busy)
+ ". Закройте программу, иначе часть файлов не удалится.")
if rule.needs_admin:
detail += "\n\nТребуются права администратора."
return Finding(
path=path,
title=rule.title,
category=rule.category,
risk=rule.risk,
size=size, files=files, dirs=dirs, mtime=mtime,
detail=detail,
group=rule.id,
is_dir=os.path.isdir(path),
delete_contents_only=rule.contents_only,
extra={"rule": rule.id, "needs_admin": rule.needs_admin,
"busy": busy, "file_masks": list(rule.files_only)},
)
def scan_rules(
opts: ScanOptions,
cancel: Cancel,
progress: Progress | None = None,
) -> tuple[list[Finding], list[str]]:
kp = known_paths()
procs = running_processes()
rules = [r for r in all_rules() if r.category in opts.categories]
jobs: list[tuple[Rule, str]] = []
for rule in rules:
for pattern in rule.paths:
for path in _expand(pattern, kp):
jobs.append((rule, path))
# дедупликация путей (одно правило может дать один и тот же путь дважды)
seen: set[tuple[str, str]] = set()
uniq_jobs = []
for rule, path in jobs:
if not on_selected_drive(path, opts.drives):
continue
key = (rule.id, os.path.normcase(os.path.abspath(path)))
if key in seen:
continue
seen.add(key)
uniq_jobs.append((rule, path))
findings: list[Finding] = []
errors: list[str] = []
total = max(1, len(uniq_jobs))
done = 0
with ThreadPoolExecutor(max_workers=opts.workers) as pool:
futures = {pool.submit(_measure, r, p, cancel, procs): (r, p) for r, p in uniq_jobs}
for fut in as_completed(futures):
done += 1
rule, path = futures[fut]
if progress is not None and done % 5 == 0:
progress(f"Проверяю: {rule.title}", done / total * 0.55)
if cancel.cancelled:
break
try:
f = fut.result()
except Exception as e: # noqa: BLE001
errors.append(f"{path}: {e}")
continue
if f is not None and f.size >= opts.min_finding_size:
findings.append(f)
# если одно правило дало несколько путей (профили браузера) — оставляем все,
# но убираем вложенные пути, чтобы не считать объём дважды
findings = _drop_nested(findings)
findings.sort(key=lambda f: -f.size)
return findings, errors
def _drop_nested(findings: list[Finding]) -> list[Finding]:
"""Убрать находки, лежащие внутри других находок того же правила."""
by_path = sorted(findings, key=lambda f: len(f.path))
kept: list[Finding] = []
roots: list[str] = []
for f in by_path:
n = os.path.normcase(os.path.abspath(f.path))
if any(n.startswith(r + os.sep) for r in roots):
continue
kept.append(f)
roots.append(n)
return kept
# ------------------------------------------------------------ полный скан ---
def full_scan(
opts: ScanOptions,
cancel: Cancel,
progress: Progress | None = None,
) -> ScanResult:
res = ScanResult(started=time.time())
def say(msg: str, frac: float) -> None:
if progress is not None:
progress(msg, min(1.0, max(0.0, frac)))
say("Собираю сведения о дисках…", 0.01)
res.disks = [disk_usage(d) for d in drives()
if on_selected_drive(d, opts.drives)]
say("Ищу кеши и мусор по каталогу правил…", 0.03)
findings, errors = scan_rules(opts, cancel, progress)
res.findings.extend(findings)
res.errors.extend(errors)
if cancel.cancelled:
res.finished = time.time()
return res
if Category.DEV_PROJECT in opts.categories:
roots = roots_for(opts)
say("Ищу артефакты сборки в проектах…", 0.58)
proj = devscan.scan_projects(
roots, cancel,
progress=lambda p: say(f"Проекты: {_short(p)}", 0.62),
min_size=opts.project_min_size,
)
res.findings.extend(proj)
if opts.scan_big_files and not cancel.cancelled:
roots = roots_for(opts)
say("Ищу крупные файлы…", 0.72)
res.findings.extend(heavy.scan_big_files(
roots, min_size=opts.big_file_min, cancel=cancel,
progress=lambda p: say(f"Крупные файлы: {_short(p)}", 0.76),
))
if opts.scan_duplicates and not cancel.cancelled:
roots = roots_for(opts)
say("Ищу дубликаты (это самая долгая часть)…", 0.80)
res.findings.extend(heavy.scan_duplicates(
roots, min_size=opts.duplicate_min, cancel=cancel,
progress=lambda p: say(f"Дубликаты: {_short(p)}", 0.85),
))
if opts.scan_programs and not cancel.cancelled:
say("Читаю список установленных программ…", 0.90)
res.programs = progmod.scan_programs(
opts.measure_program_size, cancel,
progress=lambda n: say(f"Программы: {n}", 0.93),
)
say("Формирую рекомендации…", 0.97)
res.recommendations = build_recommendations(res)
res.scanned_paths = len(res.findings)
res.finished = time.time()
say(f"Готово: найдено {human(res.total())} в {len(res.findings)} местах", 1.0)
return res
def _short(path: str, width: int = 60) -> str:
return path if len(path) <= width else "…" + path[-(width - 1):]

396
sysdevcleaner/core/util.py Normal file
View File

@@ -0,0 +1,396 @@
"""Низкоуровневые утилиты: размеры, пути, защита, корзина."""
from __future__ import annotations
import ctypes
import os
import shutil
import stat
import string
import subprocess
import sys
import threading
from typing import Callable, Iterable
IS_WIN = sys.platform == "win32"
# ---------------------------------------------------------------- размеры ----
_UNITS = ("Б", "КБ", "МБ", "ГБ", "ТБ", "ПБ")
def human(size: float) -> str:
"""1536 -> '1.5 КБ'."""
size = float(size or 0)
neg = size < 0
size = abs(size)
i = 0
while size >= 1024 and i < len(_UNITS) - 1:
size /= 1024.0
i += 1
txt = f"{size:.0f} {_UNITS[i]}" if (i == 0 or size >= 100) else f"{size:.1f} {_UNITS[i]}"
return ("-" if neg else "") + txt
class Cancelled(Exception):
pass
class Cancel:
"""Кооперативная отмена длительных обходов."""
def __init__(self) -> None:
self._e = threading.Event()
def cancel(self) -> None:
self._e.set()
def reset(self) -> None:
self._e.clear()
@property
def cancelled(self) -> bool:
return self._e.is_set()
def check(self) -> None:
if self._e.is_set():
raise Cancelled()
def dir_stats(
path: str,
cancel: "Cancel | None" = None,
max_entries: int = 400_000,
follow_links: bool = False,
) -> tuple[int, int, int, float]:
"""Размер каталога: (байты, файлов, папок, max_mtime). Устойчиво к ошибкам."""
total = files = dirs = 0
newest = 0.0
seen = 0
stack = [path]
while stack:
if cancel is not None and cancel.cancelled:
break
cur = stack.pop()
try:
it = os.scandir(cur)
except (OSError, ValueError):
continue
with it:
while True:
try:
entry = next(it)
except StopIteration:
break
except OSError:
break
seen += 1
if seen > max_entries:
stack.clear()
break
try:
if entry.is_dir(follow_symlinks=follow_links):
dirs += 1
stack.append(entry.path)
elif entry.is_file(follow_symlinks=follow_links):
st = entry.stat(follow_symlinks=False)
files += 1
total += st.st_size
if st.st_mtime > newest:
newest = st.st_mtime
except OSError:
continue
if not newest:
try:
newest = os.stat(path).st_mtime
except OSError:
newest = 0.0
return total, files, dirs, newest
def path_size(path: str, cancel: "Cancel | None" = None) -> tuple[int, int, int, float]:
"""Размер файла или каталога в том же формате, что и dir_stats."""
try:
st = os.stat(path)
except OSError:
return 0, 0, 0, 0.0
if stat.S_ISDIR(st.st_mode):
return dir_stats(path, cancel)
return st.st_size, 1, 0, st.st_mtime
# ------------------------------------------------------------------ пути ----
def env(name: str) -> str:
return os.environ.get(name, "") or ""
def expand(path: str) -> str:
return os.path.expandvars(os.path.expanduser(path))
def known_paths() -> dict[str, str]:
home = os.path.expanduser("~")
local = env("LOCALAPPDATA") or os.path.join(home, "AppData", "Local")
roaming = env("APPDATA") or os.path.join(home, "AppData", "Roaming")
return {
"HOME": home,
"LOCAL": local,
"LOCALLOW": os.path.join(os.path.dirname(local), "LocalLow") if local else "",
"ROAMING": roaming,
"TEMP": env("TEMP") or os.path.join(local, "Temp"),
"WINDIR": env("WINDIR") or "C:\\Windows",
"SYSTEMDRIVE": (env("SYSTEMDRIVE") or "C:") + "\\",
"PROGRAMDATA": env("PROGRAMDATA") or "C:\\ProgramData",
"PROGRAMFILES": env("PROGRAMFILES") or "C:\\Program Files",
"PROGRAMFILES86": env("ProgramFiles(x86)") or "C:\\Program Files (x86)",
"PUBLIC": env("PUBLIC") or "C:\\Users\\Public",
}
def drives() -> list[str]:
"""Список смонтированных дисков ('C:\\', 'D:\\')."""
if not IS_WIN:
return ["/"]
out = []
try:
mask = ctypes.windll.kernel32.GetLogicalDrives()
except Exception:
mask = 0
for i, letter in enumerate(string.ascii_uppercase):
if mask >> i & 1:
root = f"{letter}:\\"
try:
dtype = ctypes.windll.kernel32.GetDriveTypeW(ctypes.c_wchar_p(root))
except Exception:
dtype = 3
if dtype in (2, 3, 4): # removable / fixed / network
out.append(root)
return out or ["C:\\"]
def disk_usage(root: str) -> dict:
try:
u = shutil.disk_usage(root)
return {
"root": root,
"total": u.total,
"used": u.used,
"free": u.free,
"percent": (u.used / u.total * 100) if u.total else 0.0,
}
except OSError:
return {"root": root, "total": 0, "used": 0, "free": 0, "percent": 0.0}
# --------------------------------------------------------------- защита -----
def _norm(p: str) -> str:
return os.path.normcase(os.path.abspath(p)).rstrip("\\/")
def _protected_roots() -> set[str]:
k = known_paths()
raw = [
k["WINDIR"],
os.path.join(k["WINDIR"], "System32"),
os.path.join(k["WINDIR"], "SysWOW64"),
k["PROGRAMFILES"],
k["PROGRAMFILES86"],
k["SYSTEMDRIVE"],
k["HOME"],
k["LOCAL"],
k["ROAMING"],
k["PROGRAMDATA"],
os.path.join(k["HOME"], "Documents"),
os.path.join(k["HOME"], "Desktop"),
os.path.join(k["HOME"], "Downloads"),
os.path.join(k["HOME"], "Pictures"),
os.path.join(k["HOME"], "Videos"),
os.path.join(k["HOME"], "Music"),
os.path.join(k["HOME"], "OneDrive"),
]
out = {_norm(p) for p in raw if p}
out |= {_norm(d) for d in drives()}
return out
PROTECTED = _protected_roots()
def is_protected(path: str) -> bool:
"""Нельзя удалять сам путь (но можно то, что глубже внутри)."""
n = _norm(path)
if not n or len(n) <= 3:
return True
return n in PROTECTED
def is_safe_target(path: str) -> tuple[bool, str]:
"""Финальная проверка перед удалением."""
if not path:
return False, "пустой путь"
n = _norm(path)
if len(n) <= 3:
return False, "корень диска"
if is_protected(path):
return False, "системный/пользовательский корневой каталог"
depth = n.replace("/", "\\").count("\\")
if depth < 2:
return False, "слишком высокий уровень в дереве"
return True, ""
# --------------------------------------------------------------- удаление ---
def _on_rm_error(func: Callable, path: str, exc_info) -> None:
"""Снимаем read-only и пробуем ещё раз."""
try:
os.chmod(path, stat.S_IWRITE)
func(path)
except Exception:
pass
def delete_path(path: str, to_recycle_bin: bool = True) -> tuple[bool, str]:
"""Удалить файл/каталог. Возвращает (успех, сообщение об ошибке)."""
if not os.path.exists(path):
return True, ""
if to_recycle_bin and IS_WIN:
ok, err = _recycle(path)
if ok:
return True, ""
# корзина не справилась (сетевой диск, слишком большой объект) — не насилуем
return False, err
try:
if os.path.isdir(path) and not os.path.islink(path):
shutil.rmtree(path, onerror=_on_rm_error)
else:
try:
os.chmod(path, stat.S_IWRITE)
except OSError:
pass
os.remove(path)
if os.path.exists(path):
return False, "путь остался на месте"
return True, ""
except Exception as e: # noqa: BLE001
return False, str(e)
def _recycle(path: str) -> tuple[bool, str]:
"""SHFileOperationW с флагом FOF_ALLOWUNDO."""
try:
from ctypes import wintypes
class SHFILEOPSTRUCTW(ctypes.Structure):
_fields_ = [
("hwnd", wintypes.HWND),
("wFunc", wintypes.UINT),
("pFrom", wintypes.LPCWSTR),
("pTo", wintypes.LPCWSTR),
("fFlags", ctypes.c_uint16),
("fAnyOperationsAborted", wintypes.BOOL),
("hNameMappings", ctypes.c_void_p),
("lpszProgressTitle", wintypes.LPCWSTR),
]
FO_DELETE = 3
FOF_ALLOWUNDO = 0x0040
FOF_NOCONFIRMATION = 0x0010
FOF_NOERRORUI = 0x0400
FOF_SILENT = 0x0004
FOF_NOCONFIRMMKDIR = 0x0200
op = SHFILEOPSTRUCTW()
op.wFunc = FO_DELETE
op.pFrom = os.path.abspath(path) + "\0\0"
op.fFlags = (FOF_ALLOWUNDO | FOF_NOCONFIRMATION | FOF_NOERRORUI
| FOF_SILENT | FOF_NOCONFIRMMKDIR)
res = ctypes.windll.shell32.SHFileOperationW(ctypes.byref(op))
if res == 0 and not op.fAnyOperationsAborted:
return True, ""
return False, f"SHFileOperation код {res}"
except Exception as e: # noqa: BLE001
return False, str(e)
def clear_contents(path: str, to_recycle_bin: bool = True) -> tuple[int, int, list[str]]:
"""Очистить содержимое каталога, сам каталог оставить.
Возвращает (удалено, пропущено, ошибки).
"""
done = skipped = 0
errs: list[str] = []
try:
entries = list(os.scandir(path))
except OSError as e:
return 0, 0, [f"{path}: {e}"]
for entry in entries:
ok, err = delete_path(entry.path, to_recycle_bin)
if ok:
done += 1
else:
skipped += 1
if err and len(errs) < 20:
errs.append(f"{entry.name}: {err}")
return done, skipped, errs
# ------------------------------------------------------------- процессы -----
def running_processes() -> set[str]:
"""Имена запущенных процессов в нижнем регистре."""
names: set[str] = set()
if not IS_WIN:
return names
try:
out = subprocess.run(
["tasklist", "/FO", "CSV", "/NH"],
capture_output=True, text=True, timeout=20,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
errors="ignore",
).stdout
for line in out.splitlines():
line = line.strip()
if line.startswith('"'):
names.add(line.split('","')[0].strip('"').lower())
except Exception:
pass
return names
def is_admin() -> bool:
if not IS_WIN:
return os.geteuid() == 0 if hasattr(os, "geteuid") else False
try:
return bool(ctypes.windll.shell32.IsUserAnAdmin())
except Exception:
return False
def open_in_explorer(path: str) -> None:
try:
if IS_WIN:
if os.path.isdir(path):
os.startfile(path) # noqa: S606
else:
subprocess.Popen(["explorer", "/select,", os.path.abspath(path)])
else:
subprocess.Popen(["xdg-open", os.path.dirname(path)])
except Exception:
pass
def iter_globs(patterns: Iterable[str]) -> list[str]:
import glob as _g
out: list[str] = []
for p in patterns:
try:
out.extend(_g.glob(expand(p), recursive=True))
except Exception:
continue
return out