Первый выпуск 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:
328
sysdevcleaner/core/scanner.py
Normal file
328
sysdevcleaner/core/scanner.py
Normal 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):]
|
||||
Reference in New Issue
Block a user