Files
SysDevCleaner/sysdevcleaner/core/scanner.py
HeagBoKaT b01cf37e66 Первый выпуск SysDevCleaner
Анализ Windows и очистка кешей, мусора и остатков разработки.

Ядро:
- каталог правил: 228 правил путей (496 шаблонов) + 43 правила артефактов
  проектов — Windows, браузеры, кеши сборки, IDE, приложения, пакетные
  менеджеры;
- рекурсивный поиск node_modules, target, build, __pycache__, .venv и т.п.
  с проверкой маркеров проекта, чтобы не ловить ложные срабатывания;
- поиск крупных файлов и дубликатов (хеш по началу/середине/концу и полная
  сверка), инвентаризация установленных программ через реестр;
- ограничение области сканирования по дискам;
- рекомендации и текстовый отчёт.

Безопасность:
- четыре уровня риска, автоматически отмечается только «Безопасно»;
- список защищённых путей проверяется и при выборе, и перед удалением;
- для каталогов вроде %TEMP% удаляется содержимое, а не сам каталог;
- удаление в Корзину по умолчанию, пробный запуск, журнал очисток.

Интерфейс на Dear PyGui:
- JetBrains Mono Nerd Font: кириллица и иконки в одном шрифте;
- тёмная и светлая темы, адаптивная раскладка;
- заголовок окна ставится через SetWindowTextW — Dear PyGui отдаёт его
  в ANSI-функцию как UTF-8 и портит кириллицу;
- запуск всегда с правами администратора: манифест UAC в exe и
  самоперезапуск через ShellExecuteW при работе из исходников.

Сборка в один exe через PyInstaller со вшитым шрифтом и значком.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 14:55:00 +03:00

329 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Движок сканирования: раскрывает правила в конкретные находки."""
from __future__ import annotations
import 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):]