🏗 Фаза 1-4: Архитектура, ядро, проверки, API, генератор отчетов

- scanner/engine.py — Playwright-движок сканирования
- scanner/models.py — Pydantic модели
- scanner/checks/ — 5 проверок (cookie, privacy, forms, server, external)
- api/server.py — FastAPI REST API
- reports/generator.py — PDF через WeasyPrint + Jinja2
- main.py — CLI + сервер

Проверки:
✅ cookie_banner — поиск cookie-баннера
✅ privacy_link — проверка ссылки на политику
✅ form_checkbox — проверка чекбоксов согласия
✅ server_location — геолокация сервера (RФ или нет)
✅ external_scripts — внешние скрипты аналитики
This commit is contained in:
neo committed 2026-06-09 21:10:21 +05:00
commit de18090304
20 files changed
+1665

No files matched your search

View File
Whitespace-only changes.
+25
View File
@@ -0,0 +1,25 @@
from scanner.checks.base import BaseCheck
from scanner.checks.cookie_banner import CookieBannerCheck
from scanner.checks.privacy_link import PrivacyLinkCheck
from scanner.checks.form_checkbox import FormCheckboxCheck
from scanner.checks.server_location import ServerLocationCheck
from scanner.checks.external_scripts import ExternalScriptsCheck
# Реестр всех проверок — новые проверки автоматически регистрируются здесь
ALL_CHECKS: dict[str, type[BaseCheck]] = {
"cookie_banner": CookieBannerCheck,
"privacy_link": PrivacyLinkCheck,
"form_checkbox": FormCheckboxCheck,
"server_location": ServerLocationCheck,
"external_scripts": ExternalScriptsCheck,
}
__all__ = [
"BaseCheck",
"CookieBannerCheck",
"PrivacyLinkCheck",
"FormCheckboxCheck",
"ServerLocationCheck",
"ExternalScriptsCheck",
"ALL_CHECKS",
]
+76
View File
@@ -0,0 +1,76 @@
"""
Базовый класс для всех проверок 152-ФЗ.
Каждая проверка наследуется от этого класса и реализует метод check().
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from playwright.async_api import Page
from scanner.models import CheckResult, CheckStatus, RiskLevel
class BaseCheck(ABC):
"""Базовый класс для проверки соответствия 152-ФЗ."""
name: str = "base" # Уникальное имя (используется в реестре)
title: str = "Base Check" # Отображаемое название
description: str = "" # Описание что проверяет
risk_on_fail: RiskLevel = RiskLevel.LOW # Уровень риска при провале
default_weight: int = 10 # Вес (влияние на общий балл)
@abstractmethod
async def check(self, page: Page, url: str) -> CheckResult:
"""
Выполнить проверку сайта.
Args:
page: Playwright Page объект (сайт уже загружен)
url: URL проверяемого сайта
Returns:
CheckResult с результатом проверки
"""
pass
def _pass_result(self, message: str = "", details: dict = {}) -> CheckResult:
"""Создать успешный результат."""
return CheckResult(
name=self.name,
title=self.title,
status=CheckStatus.PASS,
risk_level=self.risk_on_fail,
message=message or f"✓ {self.title} — проверено, нарушений не найдено",
details=details,
)
def _fail_result(self, message: str, details: dict = {}, risk_level: RiskLevel | None = None) -> CheckResult:
"""Создать результат с провалом."""
return CheckResult(
name=self.name,
title=self.title,
status=CheckStatus.FAIL,
risk_level=risk_level or self.risk_on_fail,
message=f"✗ {message}",
details=details,
)
def _skip_result(self, message: str = "") -> CheckResult:
"""Создать результат с пропуском."""
return CheckResult(
name=self.name,
title=self.title,
status=CheckStatus.SKIP,
risk_level=RiskLevel.LOW,
message=message or f"⏭ {self.title} — пропущено",
)
def _error_result(self, message: str) -> CheckResult:
"""Создать результат с ошибкой."""
return CheckResult(
name=self.name,
title=self.title,
status=CheckStatus.ERROR,
risk_level=RiskLevel.HIGH,
message=f"⚠ {message}",
)
+89
View File
@@ -0,0 +1,89 @@
"""
Проверка наличия cookie-баннера (уведомления о cookies).
Согласно 152-ФЗ, сайт обязан получать согласие пользователя
на обработку персональных данных, включая cookies.
"""
from __future__ import annotations
import re
from playwright.async_api import Page
from scanner.checks.base import BaseCheck
from scanner.models import CheckResult, RiskLevel
class CookieBannerCheck(BaseCheck):
"""Проверяет наличие cookie-баннера на сайте."""
name = "cookie_banner"
title = "Cookie-баннер"
description = "Проверяет, отображается ли на сайте уведомление о cookie"
risk_on_fail = RiskLevel.HIGH
default_weight = 15
# Паттерны для поиска cookie-баннера
COOKIE_PATTERNS = [
r"cookie",
r"куки",
r"уведомление\s*о\s*cookie",
r"согласие\s*на\s*использование\s*cookie",
r"we\s*use\s*cookies",
r"privacy\s*policy",
r"данные\s*пользователя",
r"обработка\s*персональных\s*данных",
r"personal\s*data",
r"consent",
r"согласие",
]
async def check(self, page: Page, url: str) -> CheckResult:
try:
# Получаем весь текст страницы (включая динамически загруженный)
page_text = await page.evaluate("() => document.body.innerText")
page_html = await page.evaluate("() => document.documentElement.outerHTML")
# Ищем cookie-баннер по видимым элементам
cookie_elements = await page.query_selector_all(
'[id*="cookie"], [class*="cookie"], [id*="consent"], [class*="consent"], [id*="privacy"], [class*="privacy"], [id*="policy"], [class*="policy"]'
)
found_banner = False
banner_texts = []
for el in cookie_elements:
text = await el.inner_text()
if text:
banner_texts.append(text[:200])
# Также ищем по тексту всей страницы
has_keyword = any(
re.search(pattern, page_text, re.IGNORECASE | re.UNICODE)
for pattern in self.COOKIE_PATTERNS
)
if banner_texts:
found_banner = True
return self._pass_result(
message=f"Найден cookie-баннер ({len(banner_texts)} элементов)",
details={
"banner_count": len(banner_texts),
"sample_text": banner_texts[0][:100],
},
)
if has_keyword:
return self._pass_result(
message="На странице найдены упоминания cookie/согласия",
details={"keyword_found": True},
)
return self._fail_result(
message="Cookie-баннер не найден. Сайт собирает данные без явного согласия.",
details={
"page_text_sample": page_text[:200],
"search_patterns": self.COOKIE_PATTERNS,
},
)
except Exception as e:
return self._error_result(message=f"Ошибка при проверке cookie-баннера: {str(e)}")
+110
View File
@@ -0,0 +1,110 @@
"""
Проверка внешних скриптов, собирающих данные пользователей.
Google Analytics, Facebook Pixel, Яндекс.Метрика и другие —
отправляют данные на сервера за пределами РФ.
"""
from __future__ import annotations
import re
from playwright.async_api import Page
from scanner.checks.base import BaseCheck
from scanner.models import CheckResult, RiskLevel
class ExternalScriptsCheck(BaseCheck):
"""Проверяет наличие внешних скриптов, передающих данные за пределы РФ."""
name = "external_scripts"
title = "Внешние скрипты аналитики"
description = "Ищет скрипты Google Analytics, Facebook Pixel и другие иностранные сервисы"
risk_on_fail = RiskLevel.HIGH
default_weight = 15
EXTERNAL_PATTERNS = [
r"google-analytics\.com",
r"googletagmanager\.com",
r"googlesyndication\.com",
r"googleadservices\.com",
r"googleapis\.com",
r"google\.com/recaptcha",
r"facebook\.com/tr",
r"fbcdn\.net",
r"connect\.facebook\.net",
r"mc\.yandex\.ru",
r"monitoring\.yandex\.ru",
r"top\.mail\.ru",
r"clarity\.microsoft\.com",
r"hotjar\.com",
r"mixpanel\.com",
r"segment\.com",
r"amplitude\.com",
r"zendesk\.com",
r"intercom\.io",
r"chatra\.com",
r"tilda\.cdn",
r"wix\.com",
r"wordpress\.com",
r"shopify\.com",
]
async def check(self, page: Page, url: str) -> CheckResult:
try:
html = await page.evaluate("() => document.documentElement.outerHTML")
found_scripts = []
for pattern in self.EXTERNAL_PATTERNS:
matches = re.findall(pattern, html, re.IGNORECASE)
if matches:
found_scripts.append({
"pattern": pattern,
"matches": len(matches),
})
if not found_scripts:
return self._pass_result(
message="Внешние скрипты аналитики не найдены.",
)
critical_services = [
"google-analytics",
"googletagmanager",
"facebook",
"clarity",
"segment",
"amplitude",
]
has_critical = False
for script in found_scripts:
if any(re.search(c, script["pattern"]) for c in critical_services):
has_critical = True
break
if has_critical:
return self._fail_result(
message=(
f"Найдены внешние скрипты передачи данных: "
f"{', '.join(s['pattern'] for s in found_scripts)}. "
f"Они передают персональные данные за пределы РФ."
),
details={
"scripts_found": found_scripts,
"has_critical": True,
},
)
return self._fail_result(
message=(
f"Найдены внешние скрипты: "
f"{', '.join(s['pattern'] for s in found_scripts)}. "
f"Требуется согласие пользователя."
),
details={
"scripts_found": found_scripts,
"has_critical": False,
},
)
except Exception as e:
return self._error_result(message=f"Ошибка: {str(e)}")
+117
View File
@@ -0,0 +1,117 @@
"""
Проверка наличия чекбокса согласия на обработку персональных данных.
Согласно 152-ФЗ, обработка данных допустима только при наличии согласия субъекта.
Формы на сайте (обратная связь, регистрация, подписка и т.д.)
должны содержать явное согласие пользователя.
"""
from __future__ import annotations
import re
from playwright.async_api import Page
from scanner.checks.base import BaseCheck
from scanner.models import CheckResult, RiskLevel
class FormCheckboxCheck(BaseCheck):
"""Проверяет наличие чекбокса согласия в формах на сайте."""
name = "form_checkbox"
title = "Чекбокс согласия в формах"
description = "Проверяет, есть ли чекбокс согласия на обработку ПД в формах"
risk_on_fail = RiskLevel.CRITICAL
default_weight = 20
CONSENT_PATTERNS = [
r"согласие\s*на\s*обработку\s*данных",
r"согласие\s*на\s*обработку\s*персональных?\s*данных?",
r"я\s*согласен\s*на\s*",
r"agree\s*to\s*the\s*",
r"agree\s*to\s*privacy",
r"я\s*принимаю\s*условия",
r"processing\s*of\s*personal\s*data",
r"обработк[аею]\s*персональных?\s*данных?",
r"политик[ууе]?\s*конфиденциальност[иы]",
]
async def check(self, page: Page, url: str) -> CheckResult:
try:
# Получаем все формы на странице
forms = await page.query_selector_all("form")
if not forms:
return self._skip_result(
message="Формы на странице не найдены.",
)
forms_needing_consent = 0
forms_with_consent = 0
forms_without_consent = []
for form_idx, form in enumerate(forms):
# Проверяем, есть ли чекбокс в форме
checkboxes = await form.query_selector_all('input[type="checkbox"]')
has_consent = False
checkbox_texts = []
for cb in checkboxes:
cb_text = await cb.inner_text() if not await cb.get_attribute("value") else await cb.get_attribute("value")
cb_label = await form.evaluate(
f'(el) => {{\n'
f' const cb = el.closest("label") || el.parentElement;\n'
f' return cb ? cb.innerText : "";\n'
f'}}', form
)
checkbox_texts.append(cb_text or cb_label or "без текста")
combined = f"{cb_text} {cb_label}".lower()
if any(re.search(p, combined, re.UNICODE | re.IGNORECASE) for p in self.CONSENT_PATTERNS):
has_consent = True
forms_needing_consent += 1
if has_consent:
forms_with_consent += 1
else:
forms_without_consent.append({
"form_index": form_idx,
"checkboxes": checkbox_texts[:3],
})
if forms_with_consent == forms_needing_consent:
return self._pass_result(
message=f"Все формы ({forms_with_consent}) содержат чекбокс согласия.",
details={
"forms_count": forms_with_consent,
"checked_forms": forms_with_consent,
},
)
if forms_with_consent == 0:
return self._fail_result(
message=(
f"Формы ({forms_needing_consent}) не содержат чекбокс согласия на обработку ПД. "
f"Это прямое нарушение 152-ФЗ."
),
details={
"total_forms": forms_needing_consent,
"without_consent": forms_without_consent,
"sample_texts": [f["checkboxes"][0] for f in forms_without_consent[:3]],
},
)
# Частичное покрытие
return self._fail_result(
message=(
f"Только {forms_with_consent} из {forms_needing_consent} форм содержат чекбокс согласия."
),
details={
"total_forms": forms_needing_consent,
"with_consent": forms_with_consent,
"without_consent_count": forms_needing_consent - forms_with_consent,
},
)
except Exception as e:
return self._error_result(message=f"Ошибка при проверке чекбоксов: {str(e)}")
+92
View File
@@ -0,0 +1,92 @@
"""
Проверка наличия ссылки на Политику конфиденциальности.
Согласно 152-ФЗ, оператор обязан опубликовать документ,
определяющий его политику в отношении обработки персональных данных.
"""
from __future__ import annotations
import re
from urllib.parse import urljoin
from playwright.async_api import Page
from scanner.checks.base import BaseCheck
from scanner.models import CheckResult, RiskLevel
class PrivacyLinkCheck(BaseCheck):
"""Проверяет наличие и доступность ссылки на Политику конфиденциальности."""
name = "privacy_link"
title = "Ссылка на Политику конфиденциальности"
description = "Проверяет, есть ли на сайте ссылка на политику конфиденциальности"
risk_on_fail = RiskLevel.HIGH
default_weight = 15
PRIVACY_PATTERNS = [
r"privacy",
r"политик[аые]",
r"конфиденциальн[аыо]",
r"персональные\s*данные",
r"personal\s*data",
r"data\s*policy",
r"обработка\s*данных",
r"user\s*privacy",
]
async def check(self, page: Page, url: str) -> CheckResult:
try:
# Получаем все ссылки на странице
links = await page.query_selector_all("a[href]")
link_texts = []
for link in links:
href = await link.get_attribute("href") or ""
text = (await link.inner_text()).strip()
if text:
link_texts.append({"text": text, "href": href})
# Ищем ссылки на политику
found_links = []
for lt in link_texts:
combined = (lt["text"] + " " + lt["href"]).lower()
if any(re.search(p, combined, re.UNICODE) for p in self.PRIVACY_PATTERNS):
full_url = urljoin(url, lt["href"])
found_links.append({"text": lt["text"], "url": full_url})
if not found_links:
return self._fail_result(
message="Ссылка на Политику конфиденциальности не найдена.",
details={"total_links": len(link_texts), "page_text_sample": link_texts[:3]},
)
# Проверяем, что ссылка ведет на рабочую страницу
policy_url = found_links[0]["url"]
try:
await page.goto(policy_url, timeout=5000)
policy_text = await page.evaluate("() => document.body.innerText")
await page.goto(url, timeout=5000) # Возвращаемся на главную
if len(policy_text) < 50:
return self._fail_result(
message=f"Страница политики ({policy_url}) слишком короткая ({len(policy_text)} символов).",
details={"policy_url": policy_url, "char_count": len(policy_text)},
)
return self._pass_result(
message=f"Найдена ссылка на Политику конфиденциальности ({policy_url})",
details={
"policy_url": policy_url,
"link_text": found_links[0]["text"],
"char_count": len(policy_text),
},
)
except Exception as e:
return self._fail_result(
message=f"Страница политики не доступна: {str(e)}",
details={"policy_url": policy_url},
)
except Exception as e:
return self._error_result(message=f"Ошибка при проверке ссылки на политику: {str(e)}")
+139
View File
@@ -0,0 +1,139 @@
"""
Проверка геолокации сервера.
Согласно 152-ФЗ (ст. 18), сбор персональных данных граждан РФ
должен производиться с использованием баз данных, находящихся на территории РФ.
"""
from __future__ import annotations
from playwright.async_api import Page
from scanner.checks.base import BaseCheck
from scanner.models import CheckResult, RiskLevel
class ServerLocationCheck(BaseCheck):
"""Проверяет, находится ли сервер сайта в России."""
name = "server_location"
title = "Геолокация сервера"
description = "Проверяет, находится ли сервер сайта на территории РФ"
risk_on_fail = RiskLevel.CRITICAL
default_weight = 25
# Популярные иностранные провайдеры (можно расширять)
RUSSIAN_HOSTERS = [
"yandex", "mail.ru", "vk", "selectel", "timeweb", "beget",
"reg.ru", "nic.ru", "rtcdn", "astra", "sbercloud",
"cloud4russia", "гостех", "ростелеком", "мтс", "билайн",
"мегафон", "вымпелком", "ростелеком", "netology",
]
async def check(self, page: Page, url: str) -> CheckResult:
try:
# Получаем IP-адрес сервера через JavaScript
ip_info = await page.evaluate("""() => {
return new Promise((resolve) => {
// Пробуем определить IP через несколько методов
const methods = [
() => fetch('https://api.ipify.org?format=json')
.then(r => r.json()).then(d => ({ip: d.ip, method: 'ipify'})),
() => fetch('https://api.my-ip.io/v2/json')
.then(r => r.json()).then(d => ({ip: d.ip, method: 'my-ip-io'})),
];
// Пробуем каждый метод
(async () => {
for (const method of methods) {
try {
const result = await method();
resolve(result);
return;
} catch (e) {}
}
resolve(null);
})();
});
}""")
if not ip_info:
return self._skip_result(
message="Не удалось определить IP-адрес сервера.",
)
ip = ip_info.get("ip", "")
method = ip_info.get("method", "")
# Определяем страну по IP (используем публичный API)
country = await self._get_country(ip)
if not country:
return self._skip_result(
message=f"Не удалось определить страну для IP {ip}.",
)
# Проверяем, Россия ли это
if country.lower() == "ru" or "Россия" in country:
return self._pass_result(
message=f"Сервер находится в России (IP: {ip}, Страна: {country})",
details={
"ip": ip,
"country": country,
"method": method,
},
)
# Сервер за рубежом — критическое нарушение
return self._fail_result(
message=(
f"Сервер находится ЗА ПРЕДЕЛАМИ РОССИИ ({country}, IP: {ip}). "
f"Это нарушение ст. 18 152-ФЗ: базы данных граждан РФ должны быть в РФ."
),
details={
"ip": ip,
"country": country,
"method": method,
},
)
except Exception as e:
return self._error_result(message=f"Ошибка при проверке геолокации: {str(e)}")
async def _get_country(self, ip: str) -> str | None:
"""Получает страну по IP через публичный API."""
try:
import urllib.request
import json
# Пробуем несколько бесплатных API
apis = [
f"https://ipapi.co/{ip}/json/",
f"https://ip-api.com/json/{ip}?fields=country",
f"https://api.ipgeolocation.io/ipgeo?ip={ip}",
]
for api_url in apis:
try:
req = urllib.request.Request(
api_url,
headers={"User-Agent": "Mozilla/5.0"}
)
with urllib.request.urlopen(req, timeout=5) as response:
data = json.loads(response.read().decode())
# ipapi.co формат
if "country_name" in data:
return data["country_name"]
# ip-api.com формат
if "country" in data:
return data["country"]
# ipgeolocation формат
if "country_name" in data:
return data["country_name"]
except Exception:
continue
return None
except Exception:
return None
+127
View File
@@ -0,0 +1,127 @@
"""
Движок сканирования 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
from scanner.models import CheckResult, CheckStatus, RiskLevel, SiteScan
class ScannerEngine:
"""Управляет процессом сканирования сайтов."""
def __init__(self, timeout: int = 15000, headless: bool = True):
self.timeout = timeout
self.headless = headless
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
});
""")
# Загружаем страницу
try:
await page.goto(url, timeout=self.timeout, wait_until="networkidle")
except Exception as e:
result.checks.append(
CheckResult(
name="page_load",
title="Загрузка страницы",
status=CheckStatus.ERROR,
risk_level=RiskLevel.CRITICAL,
message=f"Не удалось загрузить страницу: {str(e)}",
)
)
return result
# Подождем, пока JavaScript отрендерит динамический контент
await page.wait_for_timeout(3000)
# Запускаем все проверки
check_names = checks if checks else list(ALL_CHECKS.keys())
tasks = []
for check_name in check_names:
check_cls = ALL_CHECKS.get(check_name)
if check_cls:
check_instance = check_cls()
tasks.append(check_instance.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.ERROR,
risk_level=RiskLevel.CRITICAL,
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) -> list[SiteScan]:
"""Параллельно сканировать несколько URL."""
tasks = [self.scan_url(url, checks) for url in urls]
return await asyncio.gather(*tasks)
# Глобальный экземпляр для переиспользования
scanner_engine = ScannerEngine()
+118
View File
@@ -0,0 +1,118 @@
"""
Pydantic модели для 152-ФЗ Scanner.
Описывают структуры данных: результаты проверок, отчеты, сканирование.
"""
from __future__ import annotations
from datetime import datetime
from enum import Enum
from typing import Any, Optional
from pydantic import BaseModel, Field
class CheckStatus(str, Enum):
PASS = "pass" # ✅ Проверка пройдена
FAIL = "fail" # ❌ Проверка не пройдена
SKIP = "skip" # ⚠️ Проверка пропущена
ERROR = "error" # 🔴 Ошибка при проверке
class RiskLevel(str, Enum):
LOW = "low" # 🟢 Низкий риск
MEDIUM = "medium" # 🟡 Средний риск
HIGH = "high" # 🔴 Высокий риск
CRITICAL = "critical" # 🚫 Критический риск
class CheckResult(BaseModel):
"""Результат одной проверки."""
name: str # Имя проверки (например, "cookie_banner")
title: str # Название для отображения (например, "Cookie-баннер")
status: CheckStatus # Статус проверки
risk_level: RiskLevel = RiskLevel.LOW # Уровень риска при провале
message: str = "" # Описание результата
details: dict[str, Any] = {} # Детали для отладки/отчета
class SiteScan(BaseModel):
"""Результат сканирования одного сайта."""
url: str # URL сайта
scanned_at: datetime # Время сканирования
checks: list[CheckResult] = [] # Результаты всех проверок
score: int = 100 # Общий балл (0-100)
risks: list[CheckResult] = [] # Проваленные проверки (только fail)
critical_risks: list[CheckResult] = [] # Критические провалы
is_compliant: bool = True # Соответствует ли 152-ФЗ
summary: str = "" # Краткое резюме
def _calculate_score(self):
"""Вычисляет итоговый балл и статус соответствия."""
risk_weights = {
RiskLevel.LOW: 5,
RiskLevel.MEDIUM: 10,
RiskLevel.HIGH: 20,
RiskLevel.CRITICAL: 30,
}
score = 100
risks = []
critical_risks = []
for check in self.checks:
if check.status == CheckStatus.FAIL:
penalty = risk_weights.get(check.risk_level, 5)
score -= penalty
risks.append(check)
if check.risk_level == RiskLevel.CRITICAL:
critical_risks.append(check)
self.score = max(0, score)
self.risks = risks
self.critical_risks = critical_risks
self.is_compliant = self.score >= 70
# Формируем краткое резюме
fail_count = sum(1 for c in self.checks if c.status == CheckStatus.FAIL)
error_count = sum(1 for c in self.checks if c.status == CheckStatus.ERROR)
skip_count = sum(1 for c in self.checks if c.status == CheckStatus.SKIP)
if self.is_compliant:
emoji = "🟢" if self.score >= 90 else "🟡"
self.summary = f"{emoji} Сайт частично соответствует 152-ФЗ (балл: {self.score}/100). Найдено нарушений: {fail_count}."
else:
emoji = "🔴" if self.critical_risks else "🟠"
self.summary = f"{emoji} Сайт НЕ соответствует 152-ФЗ (балл: {self.score}/100). Критических нарушений: {len(self.critical_risks)}. Требуется доработка."
return self
class ScanRequest(BaseModel):
"""Запрос на сканирование (через API)."""
url: str = Field(..., description="URL сайта для сканирования")
checks: Optional[list[str]] = None # Какие проверки запустить (все если None)
class CSVScanRequest(BaseModel):
"""Запрос на сканирование CSV-файла."""
urls: list[str] = Field(..., description="Список URL для сканирования")
class ScanResponse(BaseModel):
"""Ответ API — результат сканирования."""
scan_id: str # Уникальный ID сканирования
status: str # pending / completed / error
url: Optional[str] = None # URL (если одно)
urls_count: Optional[int] = None # Количество URL (если CSV)
results: Optional[list[SiteScan]] = None # Результаты
error: Optional[str] = None # Ошибка (если есть)
created_at: datetime # Время создания запроса
class PDFReport(BaseModel):
"""Данные для генерации PDF-отчета."""
site_scan: SiteScan # Результаты сканирования
generated_at: datetime # Время генерации
company_name: Optional[str] = None # Название компании (если известно)
contact_email: Optional[str] = None # Email для связи
scanner_version: str = "1.0.0" # Версия сканера