Анализ 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>
329 lines
12 KiB
Python
329 lines
12 KiB
Python
"""Движок сканирования: раскрывает правила в конкретные находки."""
|
||
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):]
|