""" Движок сканирования 152-ФЗ. Использует Playwright для загрузки сайтов и выполнения проверок. """ from __future__ import annotations import asyncio import uuid from datetime import datetime from typing import Any from playwright.async_api import async_playwright, Page from scanner.checks import ALL_CHECKS, QUICK_CHECKS from scanner.models import CheckResult, CheckStatus, RiskLevel, SiteScan # Retry-настройки MAX_RETRIES = 3 RETRY_DELAY_BASE = 2 # секунды RETRYABLE_ERRORS = ( "ERR_NAME_NOT_RESOLVED", "ERR_CONNECTION_REFUSED", "ERR_CONNECTION_RESET", "ERR_TIMED_OUT", "ERR_NETWORK_CHANGED", "net::ERR_", "Timeout", ) class ScannerEngine: """Управляет процессом сканирования сайтов.""" def __init__(self, timeout: int = 15000, headless: bool = True): self.timeout = timeout self.headless = headless async def _retry_goto(self, page: Page, url: str) -> None: """Загрузить страницу с повторными попытками при transient-ошибках.""" last_error = None for attempt in range(1, MAX_RETRIES + 1): try: await page.goto(url, timeout=self.timeout, wait_until="networkidle") return # Успех except Exception as e: last_error = e err_str = str(e) is_retryable = any(pattern in err_str for pattern in RETRYABLE_ERRORS) if not is_retryable or attempt == MAX_RETRIES: raise delay = RETRY_DELAY_BASE * (2 ** (attempt - 1)) await asyncio.sleep(delay) raise last_error # type: ignore[misc] async def scan_url(self, url: str, checks: list[str] | None = None) -> SiteScan: """ Просканировать один URL. Args: url: URL для сканирования checks: список имен проверок (None = все) Returns: SiteScan с результатами """ scan_id = str(uuid.uuid4())[:8] result = SiteScan(url=url, scanned_at=datetime.now()) async with async_playwright() as p: browser = await p.chromium.launch( headless=self.headless, args=[ "--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage", "--disable-gpu", ], ) try: context = await browser.new_context( viewport={"width": 1920, "height": 1080}, user_agent=( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/120.0.0.0 Safari/537.36" ), ) page = await context.new_page() # Устанавливаем stealth (отключаем webdriver flag) await page.add_init_script(""" Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); """) # Загружаем страницу с retry try: await self._retry_goto(page, url) except Exception as e: result.checks.append( CheckResult( name="page_load", title="Загрузка страницы", status=CheckStatus.FAIL, risk_level=RiskLevel.CRITICAL, message=f"Не удалось загрузить страницу: {str(e)}", ) ) result._calculate_score() return result # Подождем, пока JavaScript отрендерит динамический контент await page.wait_for_timeout(3000) # Запускаем проверки (по умолчанию — быстрые, без LLM) check_names = checks if checks else QUICK_CHECKS # Фаза 1: cookie_audit — ОДИН (кликает по кнопке, меняет DOM) # Не может работать параллельно с остальными проверками mutating_checks = {"cookie_audit"} phase1_names = [n for n in check_names if n in mutating_checks] phase2_names = [n for n in check_names if n not in mutating_checks] for check_name in phase1_names: check_cls = ALL_CHECKS.get(check_name) if check_cls: try: cr = await check_cls().check(page, url) if isinstance(cr, CheckResult): result.checks.append(cr) except Exception as e: result.checks.append( CheckResult( name=check_name, title=check_name, status=CheckStatus.FAIL, risk_level=RiskLevel.HIGH, message=f"Ошибка: {str(e)}", ) ) # Фаза 2: остальные проверки — параллельно (только читают DOM) tasks = [] for check_name in phase2_names: check_cls = ALL_CHECKS.get(check_name) if check_cls: tasks.append(check_cls().check(page, url)) if tasks: check_results = await asyncio.gather(*tasks, return_exceptions=True) for cr in check_results: if isinstance(cr, CheckResult): result.checks.append(cr) elif isinstance(cr, Exception): result.checks.append( CheckResult( name="unknown", title="Неизвестная ошибка", status=CheckStatus.FAIL, risk_level=RiskLevel.HIGH, message=f"Необработанная ошибка: {str(cr)}", ) ) finally: await browser.close() # Вычисляем итоговые метрики result._calculate_score() return result async def scan_urls_batch(self, urls: list[str], checks: list[str] | None = None, concurrency: int = 5) -> list[SiteScan]: """Параллельно сканировать несколько URL с ограничением конкурентности.""" semaphore = asyncio.Semaphore(concurrency) async def _scan_with_limit(url): async with semaphore: return await self.scan_url(url, checks) tasks = [_scan_with_limit(url) for url in urls] return await asyncio.gather(*tasks) # Глобальный экземпляр для переиспользования scanner_engine = ScannerEngine()