v2.0: исправлены критические баги, добавлены 4 новые проверки, 4-фазный cookie-аудит, retry-логика, YAML-словари, LLM-анализ, тесты
Исправлено: - engine: _calculate_score() при ошибке загрузки (score не 100 для мёртвых сайтов) - privacy_link: замена page.goto() на aiohttp (гонка данных) - form_checkbox: 3-уровневый поиск label + dark-pattern detection - server_location: синхронный urllib → aiohttp - api/server: перепутанные request/response модели в batch - main.py: csv --checks флаг - engine: фазовая модель (cookie_audit отдельно, остальные параллельно) - engine: retry-логика (3 попытки, exponential backoff) - PDF: emoji заменены на текст (WeasyPrint) Добавлено: - consent_doc: проверка отдельного согласия на обработку ПД - operator_info: проверка ИНН/ОГРН/DPO (читает главную + политику) - cookie_audit: 4-фазный аудит (load→detect→reject→compare) - llm_policy: опциональный LLM-анализ 16 разделов (--deep) - external_scripts: YAML-словари trackers.yaml (critical/warning/russian) - logging (output.py), .gitignore, pyproject.toml - smoke-тесты (16/16)
This commit is contained in:
1 parent
7fb417dad5
commit
b9db935389
24 files changed
+1725
-286
No files matched your search
+74
-15
@@ -11,9 +11,22 @@ from typing import Any
|
||||
|
||||
from playwright.async_api import async_playwright, Page
|
||||
|
||||
from scanner.checks import ALL_CHECKS
|
||||
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:
|
||||
"""Управляет процессом сканирования сайтов."""
|
||||
@@ -22,6 +35,23 @@ class ScannerEngine:
|
||||
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.
|
||||
@@ -66,33 +96,56 @@ class ScannerEngine:
|
||||
});
|
||||
""")
|
||||
|
||||
# Загружаем страницу
|
||||
# Загружаем страницу с retry
|
||||
try:
|
||||
await page.goto(url, timeout=self.timeout, wait_until="networkidle")
|
||||
await self._retry_goto(page, url)
|
||||
except Exception as e:
|
||||
result.checks.append(
|
||||
CheckResult(
|
||||
name="page_load",
|
||||
title="Загрузка страницы",
|
||||
status=CheckStatus.ERROR,
|
||||
status=CheckStatus.FAIL,
|
||||
risk_level=RiskLevel.CRITICAL,
|
||||
message=f"Не удалось загрузить страницу: {str(e)}",
|
||||
)
|
||||
)
|
||||
result._calculate_score()
|
||||
return result
|
||||
|
||||
# Подождем, пока JavaScript отрендерит динамический контент
|
||||
await page.wait_for_timeout(3000)
|
||||
|
||||
# Запускаем все проверки
|
||||
check_names = checks if checks else list(ALL_CHECKS.keys())
|
||||
# Запускаем проверки (по умолчанию — быстрые, без LLM)
|
||||
check_names = checks if checks else QUICK_CHECKS
|
||||
|
||||
tasks = []
|
||||
for check_name in check_names:
|
||||
# Фаза 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:
|
||||
check_instance = check_cls()
|
||||
tasks.append(check_instance.check(page, url))
|
||||
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)
|
||||
@@ -104,8 +157,8 @@ class ScannerEngine:
|
||||
CheckResult(
|
||||
name="unknown",
|
||||
title="Неизвестная ошибка",
|
||||
status=CheckStatus.ERROR,
|
||||
risk_level=RiskLevel.CRITICAL,
|
||||
status=CheckStatus.FAIL,
|
||||
risk_level=RiskLevel.HIGH,
|
||||
message=f"Необработанная ошибка: {str(cr)}",
|
||||
)
|
||||
)
|
||||
@@ -117,9 +170,15 @@ class ScannerEngine:
|
||||
result._calculate_score()
|
||||
return result
|
||||
|
||||
async def scan_urls_batch(self, urls: list[str], checks: list[str] | None = None) -> list[SiteScan]:
|
||||
"""Параллельно сканировать несколько URL."""
|
||||
tasks = [self.scan_url(url, checks) for url in urls]
|
||||
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)
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user