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
@@ -4,22 +4,35 @@ 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
|
||||
from scanner.checks.consent_doc import ConsentDocCheck
|
||||
from scanner.checks.operator_info import OperatorInfoCheck
|
||||
from scanner.checks.cookie_audit import CookieAuditCheck
|
||||
from scanner.checks.llm_policy import LLMPolicyCheck
|
||||
|
||||
# Реестр всех проверок — новые проверки автоматически регистрируются здесь
|
||||
# Реестр всех проверок
|
||||
ALL_CHECKS: dict[str, type[BaseCheck]] = {
|
||||
"cookie_banner": CookieBannerCheck,
|
||||
"cookie_audit": CookieAuditCheck,
|
||||
"privacy_link": PrivacyLinkCheck,
|
||||
"form_checkbox": FormCheckboxCheck,
|
||||
"server_location": ServerLocationCheck,
|
||||
"external_scripts": ExternalScriptsCheck,
|
||||
"consent_doc": ConsentDocCheck,
|
||||
"operator_info": OperatorInfoCheck,
|
||||
"llm_policy": LLMPolicyCheck, # Опциональный, запускается отдельно
|
||||
}
|
||||
|
||||
QUICK_CHECKS = [
|
||||
"cookie_audit", "privacy_link", "form_checkbox",
|
||||
"server_location", "external_scripts",
|
||||
"consent_doc", "operator_info",
|
||||
]
|
||||
"""Быстрые проверки без LLM — запускаются по умолчанию."""
|
||||
|
||||
__all__ = [
|
||||
"BaseCheck",
|
||||
"CookieBannerCheck",
|
||||
"PrivacyLinkCheck",
|
||||
"FormCheckboxCheck",
|
||||
"ServerLocationCheck",
|
||||
"ExternalScriptsCheck",
|
||||
"ALL_CHECKS",
|
||||
"CookieBannerCheck", "PrivacyLinkCheck", "FormCheckboxCheck",
|
||||
"ServerLocationCheck", "ExternalScriptsCheck",
|
||||
"ConsentDocCheck", "OperatorInfoCheck", "LLMPolicyCheck",
|
||||
"ALL_CHECKS", "QUICK_CHECKS",
|
||||
]
|
||||
@@ -0,0 +1,76 @@
|
||||
"""
|
||||
Проверка наличия отдельного документа «Согласие на обработку персональных данных».
|
||||
С 01.09.2025 согласие — отдельный документ (нельзя включать в оферту/договор).
|
||||
"""
|
||||
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 ConsentDocCheck(BaseCheck):
|
||||
"""Проверяет наличие отдельной страницы/документа согласия на обработку ПД."""
|
||||
|
||||
name = "consent_doc"
|
||||
title = "Документ согласия на обработку ПД"
|
||||
description = "Проверяет, есть ли на сайте отдельный документ согласия (не в составе оферты)"
|
||||
risk_on_fail = RiskLevel.CRITICAL
|
||||
default_weight = 20
|
||||
|
||||
CONSENT_LINK_PATTERNS = [
|
||||
r"согласи[ея]\s*на\s*обработку\s*(персональных)?\s*данных",
|
||||
r"согласи[ея]\s*на\s*обработку\s*пд",
|
||||
r"consent\s*(to|for)\s*(personal\s*)?data\s*processing",
|
||||
r"согласи[ея]\s*субъекта",
|
||||
]
|
||||
|
||||
async def check(self, page: Page, url: str) -> CheckResult:
|
||||
try:
|
||||
links = await page.query_selector_all("a[href]")
|
||||
found = []
|
||||
|
||||
for link in links:
|
||||
href = await link.get_attribute("href") or ""
|
||||
text = (await link.inner_text()).strip()
|
||||
combined = (text + " " + href).lower()
|
||||
|
||||
if not text or len(text) < 3:
|
||||
continue
|
||||
|
||||
if any(re.search(p, combined, re.UNICODE | re.IGNORECASE) for p in self.CONSENT_LINK_PATTERNS):
|
||||
# Не учитываем ссылки на политику конфиденциальности
|
||||
if re.search(r"политик[аеу]|privacy\s*policy|конфиденциальн", combined, re.IGNORECASE):
|
||||
continue
|
||||
# Не учитываем текст внутри чекбоксов форм (это проверяет form_checkbox)
|
||||
full_url = urljoin(url, href)
|
||||
found.append({"text": text[:100], "url": full_url})
|
||||
|
||||
if not found:
|
||||
return self._fail_result(
|
||||
message=(
|
||||
"Отдельный документ «Согласие на обработку ПД» не найден. "
|
||||
"С 01.09.2025 согласие — отдельный документ, не может быть частью оферты."
|
||||
),
|
||||
details={"total_links": len(links)},
|
||||
)
|
||||
|
||||
# Проверяем, что хотя бы одна ссылка ведёт на отдельную страницу (не PDF)
|
||||
for item in found:
|
||||
if not item["url"].lower().endswith(('.pdf', '.doc', '.docx')):
|
||||
return self._pass_result(
|
||||
message=f"Найден документ согласия: {item['text'][:80]}",
|
||||
details={"found_links": found},
|
||||
)
|
||||
|
||||
return self._pass_result(
|
||||
message=f"Найден документ согласия (PDF/DOC): {found[0]['text'][:80]}",
|
||||
details={"found_links": found, "format": "document"},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return self._error_result(f"Ошибка проверки согласия: {str(e)}")
|
||||
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
4-фазный cookie-аудит по методологии gdpr-cookie-scanner (slashgear).
|
||||
Фазы: 1) запись cookies до действий, 2) поиск consent-баннера,
|
||||
3) нажатие «Отказаться» + запись cookies после, 4) сравнение.
|
||||
Заменяет примитивную проверку «есть ли баннер?».
|
||||
"""
|
||||
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 CookieAuditCheck(BaseCheck):
|
||||
"""4-фазный аудит cookie-consent: load→detect→reject→compare."""
|
||||
|
||||
name = "cookie_audit"
|
||||
title = "4-фазный cookie-аудит"
|
||||
description = "Проверяет, что отказ от cookies реально отключает трекинговые cookies"
|
||||
risk_on_fail = RiskLevel.CRITICAL
|
||||
default_weight = 25
|
||||
|
||||
# Селекторы для поиска кнопки отказа
|
||||
REJECT_SELECTORS = [
|
||||
'button:has-text("Отказаться")',
|
||||
'button:has-text("отказаться")',
|
||||
'button:has-text("Reject")',
|
||||
'button:has-text("reject")',
|
||||
'button:has-text("Только необходимые")',
|
||||
'button:has-text("only necessary")',
|
||||
'a:has-text("Отказаться")',
|
||||
'a:has-text("Reject")',
|
||||
'[id*="reject"]',
|
||||
'[class*="reject"]',
|
||||
'[id*="decline"]',
|
||||
'[class*="decline"]',
|
||||
'[data-action="reject"]',
|
||||
'[aria-label*="Reject"]',
|
||||
'button:has-text("Запретить")',
|
||||
'button:has-text("Не согласен")',
|
||||
]
|
||||
|
||||
# Домены tracking-cookies (должны отключиться после отказа)
|
||||
TRACKING_DOMAINS = [
|
||||
"google-analytics", "googletagmanager", "doubleclick",
|
||||
"facebook", "meta", "yandex", "mc.yandex",
|
||||
"twitter", "tiktok", "hotjar", "mixpanel",
|
||||
"amplitude", "segment", "clarity",
|
||||
]
|
||||
|
||||
async def check(self, page: Page, url: str) -> CheckResult:
|
||||
try:
|
||||
# Фаза 1: записываем cookies до действий
|
||||
cookies_before = await page.context.cookies()
|
||||
tracking_before = self._filter_tracking(cookies_before)
|
||||
tracking_count_before = len(tracking_before)
|
||||
|
||||
# Фаза 2: ищем consent-баннер
|
||||
has_banner = await self._detect_banner(page)
|
||||
if not has_banner:
|
||||
# Нет баннера вообще — но проверяем, есть ли tracking-куки
|
||||
if tracking_count_before == 0:
|
||||
return self._pass_result(
|
||||
message="Cookie-баннер не найден, но tracking-cookies отсутствуют. Вероятно, сайт не использует трекинг.",
|
||||
details={"has_banner": False, "tracking_cookies": 0},
|
||||
)
|
||||
return self._fail_result(
|
||||
message=f"Cookie-баннер не найден, но обнаружено {tracking_count_before} tracking-cookie(ies) без согласия.",
|
||||
details={
|
||||
"has_banner": False,
|
||||
"tracking_cookies_before": tracking_count_before,
|
||||
"tracking_domains": [c["domain"] for c in tracking_before],
|
||||
},
|
||||
)
|
||||
|
||||
# Фаза 3: ищем кнопку отказа и нажимаем
|
||||
reject_clicked = await self._click_reject(page)
|
||||
if not reject_clicked:
|
||||
return self._fail_result(
|
||||
message="Cookie-баннер найден, но кнопка отказа не обнаружена (dark pattern: только «Принять»).",
|
||||
risk_level=RiskLevel.CRITICAL,
|
||||
details={
|
||||
"has_banner": True,
|
||||
"reject_button_found": False,
|
||||
"tracking_cookies_before": tracking_count_before,
|
||||
},
|
||||
)
|
||||
|
||||
# Ждём применения отказа
|
||||
await page.wait_for_timeout(1500)
|
||||
|
||||
# Фаза 4: сравниваем cookies после отказа
|
||||
cookies_after = await page.context.cookies()
|
||||
tracking_after = self._filter_tracking(cookies_after)
|
||||
tracking_count_after = len(tracking_after)
|
||||
|
||||
if tracking_count_after == 0:
|
||||
return self._pass_result(
|
||||
message=f"Cookie-аудит пройден: отказ работает. {tracking_count_before} tracking-cookie(ies) отключены.",
|
||||
details={
|
||||
"has_banner": True,
|
||||
"reject_clicked": True,
|
||||
"cookies_before": tracking_count_before,
|
||||
"cookies_after": tracking_count_after,
|
||||
},
|
||||
)
|
||||
|
||||
# Часть cookies осталась после отказа
|
||||
removed = tracking_count_before - tracking_count_after
|
||||
still_present = [c["domain"] for c in tracking_after]
|
||||
|
||||
if removed == 0:
|
||||
return self._fail_result(
|
||||
message=f"ОТКАЗ НЕ РАБОТАЕТ: все {tracking_count_before} tracking-cookie(ies) остались после отказа: {', '.join(still_present[:5])}.",
|
||||
risk_level=RiskLevel.CRITICAL,
|
||||
details={
|
||||
"cookies_before": tracking_count_before,
|
||||
"cookies_after": tracking_count_after,
|
||||
"still_present": still_present,
|
||||
},
|
||||
)
|
||||
|
||||
return self._fail_result(
|
||||
message=f"Отказ частичный: {removed}/{tracking_count_before} cookies отключены. Остались: {', '.join(still_present[:5])}.",
|
||||
risk_level=RiskLevel.HIGH,
|
||||
details={
|
||||
"cookies_before": tracking_count_before,
|
||||
"cookies_after": tracking_count_after,
|
||||
"removed": removed,
|
||||
"still_present": still_present,
|
||||
},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return self._error_result(f"Ошибка cookie-аудита: {str(e)}")
|
||||
|
||||
async def _detect_banner(self, page: Page) -> bool:
|
||||
"""Фаза 2: поиск consent-баннера."""
|
||||
selectors = [
|
||||
'[id*="cookie"]', '[class*="cookie"]',
|
||||
'[id*="consent"]', '[class*="consent"]',
|
||||
'[id*="gdpr"]', '[class*="gdpr"]',
|
||||
'[id*="privacy"]', '[class*="privacy"]',
|
||||
'[aria-label*="cookie" i]', '[aria-label*="consent" i]',
|
||||
]
|
||||
for sel in selectors:
|
||||
el = await page.query_selector(sel)
|
||||
if el:
|
||||
is_visible = await el.is_visible()
|
||||
if is_visible:
|
||||
return True
|
||||
return False
|
||||
|
||||
async def _click_reject(self, page: Page) -> bool:
|
||||
"""Фаза 3: найти и нажать кнопку отказа."""
|
||||
for sel in self.REJECT_SELECTORS:
|
||||
try:
|
||||
el = await page.query_selector(sel)
|
||||
if el:
|
||||
is_visible = await el.is_visible()
|
||||
if is_visible:
|
||||
await el.click(timeout=3000)
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
return False
|
||||
|
||||
def _filter_tracking(self, cookies: list[dict]) -> list[dict]:
|
||||
"""Отфильтровать tracking-cookies по доменным паттернам."""
|
||||
tracking = []
|
||||
for c in cookies:
|
||||
domain = c.get("domain", "")
|
||||
name = c.get("name", "").lower()
|
||||
# Проверяем домен
|
||||
for td in self.TRACKING_DOMAINS:
|
||||
if td in domain.lower():
|
||||
tracking.append(c)
|
||||
break
|
||||
else:
|
||||
# Проверяем имя cookie на паттерны трекинга
|
||||
tracking_names = ["_ga", "_gid", "_gat", "_fbp", "_ym_", "amplitude",
|
||||
"mp_", "ajs_", "_hj", "_clck", "_ttp"]
|
||||
if any(name.startswith(tn) for tn in tracking_names):
|
||||
tracking.append(c)
|
||||
return tracking
|
||||
@@ -1,110 +1,99 @@
|
||||
"""
|
||||
Проверка внешних скриптов, собирающих данные пользователей.
|
||||
Google Analytics, Facebook Pixel, Яндекс.Метрика и другие —
|
||||
отправляют данные на сервера за пределами РФ.
|
||||
Загружает словари трекеров из dictionaries/trackers.yaml.
|
||||
Разделяет на категории: CRITICAL (запрещены), WARNING (рискованны), RUSSIAN (допустимы).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
from playwright.async_api import Page
|
||||
|
||||
from scanner.checks.base import BaseCheck
|
||||
from scanner.models import CheckResult, RiskLevel
|
||||
|
||||
# Путь к словарю трекеров
|
||||
TRACKERS_YAML = Path(__file__).parent.parent.parent / "dictionaries" / "trackers.yaml"
|
||||
|
||||
|
||||
class ExternalScriptsCheck(BaseCheck):
|
||||
"""Проверяет наличие внешних скриптов, передающих данные за пределы РФ."""
|
||||
|
||||
name = "external_scripts"
|
||||
title = "Внешние скрипты аналитики"
|
||||
description = "Ищет скрипты Google Analytics, Facebook Pixel и другие иностранные сервисы"
|
||||
description = "Ищет скрипты Google Analytics, Meta 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",
|
||||
]
|
||||
def _load_trackers(self) -> dict:
|
||||
"""Загрузить словарь трекеров из YAML."""
|
||||
if not TRACKERS_YAML.exists():
|
||||
# Fallback: базовый набор
|
||||
return {
|
||||
"critical": [{"domain": "google-analytics.com"}, {"domain": "facebook.com/tr"}],
|
||||
"warning": [{"domain": "fonts.googleapis.com"}, {"domain": "google.com/recaptcha"}],
|
||||
"russian": [{"domain": "mc.yandex.ru"}, {"domain": "vk.com/rtrg"}],
|
||||
}
|
||||
with open(TRACKERS_YAML, "r", encoding="utf-8") as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
async def check(self, page: Page, url: str) -> CheckResult:
|
||||
try:
|
||||
html = await page.evaluate("() => document.documentElement.outerHTML")
|
||||
trackers = self._load_trackers()
|
||||
|
||||
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),
|
||||
})
|
||||
found = {"critical": [], "warning": [], "russian": []}
|
||||
|
||||
if not found_scripts:
|
||||
for category in ("critical", "warning", "russian"):
|
||||
for entry in trackers.get(category, []):
|
||||
domain = entry["domain"]
|
||||
if re.search(domain, html, re.IGNORECASE):
|
||||
found[category].append(entry)
|
||||
|
||||
if not any(found.values()):
|
||||
return self._pass_result(
|
||||
message="Внешние скрипты аналитики не найдены.",
|
||||
)
|
||||
|
||||
critical_services = [
|
||||
"google-analytics",
|
||||
"googletagmanager",
|
||||
"facebook",
|
||||
"clarity",
|
||||
"segment",
|
||||
"amplitude",
|
||||
]
|
||||
# Формируем сообщение
|
||||
parts = []
|
||||
if found["critical"]:
|
||||
domains = [e["domain"] for e in found["critical"]]
|
||||
parts.append(f"ЗАПРЕЩЁННЫЕ ({len(found['critical'])}): {', '.join(domains)}")
|
||||
if found["warning"]:
|
||||
domains = [e["domain"] for e in found["warning"]]
|
||||
parts.append(f"РИСКОВАННЫЕ ({len(found['warning'])}): {', '.join(domains)}")
|
||||
if found["russian"]:
|
||||
domains = [e["domain"] for e in found["russian"]]
|
||||
parts.append(f"Российские ({len(found['russian'])}): {', '.join(domains)}")
|
||||
|
||||
has_critical = False
|
||||
for script in found_scripts:
|
||||
if any(re.search(c, script["pattern"]) for c in critical_services):
|
||||
has_critical = True
|
||||
break
|
||||
message = "Найдены внешние скрипты: " + " | ".join(parts)
|
||||
|
||||
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,
|
||||
},
|
||||
# Только российские → PASS с уведомлением
|
||||
if not found["critical"] and not found["warning"]:
|
||||
return self._pass_result(
|
||||
message=f"Найдены только российские сервисы: {message}",
|
||||
details={"found": found},
|
||||
)
|
||||
|
||||
# Есть критические → FAIL
|
||||
if found["critical"]:
|
||||
actions = [f"{e['domain']}: {e.get('action', 'Удалить')}" for e in found["critical"]]
|
||||
message += f"\nРекомендации: {'; '.join(actions)}"
|
||||
return self._fail_result(
|
||||
message=message,
|
||||
risk_level=RiskLevel.CRITICAL,
|
||||
details={"found": found},
|
||||
)
|
||||
|
||||
# Только warning → FAIL с HIGH
|
||||
return self._fail_result(
|
||||
message=(
|
||||
f"Найдены внешние скрипты: "
|
||||
f"{', '.join(s['pattern'] for s in found_scripts)}. "
|
||||
f"Требуется согласие пользователя."
|
||||
),
|
||||
details={
|
||||
"scripts_found": found_scripts,
|
||||
"has_critical": False,
|
||||
},
|
||||
message=message,
|
||||
risk_level=RiskLevel.HIGH,
|
||||
details={"found": found},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return self._error_result(message=f"Ошибка: {str(e)}")
|
||||
return self._error_result(message=f"Ошибка при проверке внешних скриптов: {str(e)}")
|
||||
@@ -18,7 +18,7 @@ class FormCheckboxCheck(BaseCheck):
|
||||
|
||||
name = "form_checkbox"
|
||||
title = "Чекбокс согласия в формах"
|
||||
description = "Проверяет, есть ли чекбокс согласия на обработку ПД в формах"
|
||||
description = "Проверяет, есть ли чекбокс согласия на обработку ПД в формах, не предзаполнен ли он"
|
||||
risk_on_fail = RiskLevel.CRITICAL
|
||||
default_weight = 20
|
||||
|
||||
@@ -31,85 +31,113 @@ class FormCheckboxCheck(BaseCheck):
|
||||
r"я\s*принимаю\s*условия",
|
||||
r"processing\s*of\s*personal\s*data",
|
||||
r"обработк[аею]\s*персональных?\s*данных?",
|
||||
r"политик[ууе]?\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="Формы на странице не найдены.",
|
||||
)
|
||||
return self._skip_result(message="Формы на странице не найдены.")
|
||||
|
||||
forms_needing_consent = 0
|
||||
total_forms = 0
|
||||
forms_with_consent = 0
|
||||
dark_patterns = [] # Предзаполненные чекбоксы
|
||||
forms_without_consent = []
|
||||
|
||||
for form_idx, form in enumerate(forms):
|
||||
# Проверяем, есть ли чекбокс в форме
|
||||
for form in forms:
|
||||
total_forms += 1
|
||||
checkboxes = await form.query_selector_all('input[type="checkbox"]')
|
||||
if not checkboxes:
|
||||
forms_without_consent.append({"reason": "нет чекбоксов"})
|
||||
continue
|
||||
|
||||
has_consent = False
|
||||
checkbox_texts = []
|
||||
form_has_consent = False
|
||||
form_has_dark_pattern = False
|
||||
|
||||
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
|
||||
)
|
||||
# Правильная детекция текста чекбокса:
|
||||
# 1. Ищем связанный <label for="checkbox_id">
|
||||
# 2. Если нет — родительский <label>
|
||||
# 3. Если нет — соседние текстовые узлы
|
||||
checkbox_id = await cb.get_attribute("id")
|
||||
label_text = ""
|
||||
|
||||
checkbox_texts.append(cb_text or cb_label or "без текста")
|
||||
if checkbox_id:
|
||||
# Метод 1: label[for="id"]
|
||||
label_el = await page.query_selector(f'label[for="{checkbox_id}"]')
|
||||
if label_el:
|
||||
label_text = (await label_el.inner_text()).strip()
|
||||
|
||||
if not label_text:
|
||||
# Метод 2: родительский label (чекбокс внутри label)
|
||||
parent_label = await cb.evaluate("""
|
||||
(el) => {
|
||||
let parent = el.parentElement;
|
||||
while (parent) {
|
||||
if (parent.tagName === 'LABEL') return parent.innerText;
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
""")
|
||||
label_text = parent_label.strip() if parent_label else ""
|
||||
|
||||
combined = f"{cb_text} {cb_label}".lower()
|
||||
if not label_text:
|
||||
# Метод 3: aria-label / aria-labelledby
|
||||
aria_labelledby = await cb.get_attribute("aria-labelledby")
|
||||
if aria_labelledby:
|
||||
ref_el = await page.query_selector(f'#{aria_labelledby}')
|
||||
if ref_el:
|
||||
label_text = (await ref_el.inner_text()).strip()
|
||||
|
||||
# Проверяем dark pattern: предзаполненный чекбокс
|
||||
is_checked = await cb.is_checked()
|
||||
if is_checked:
|
||||
form_has_dark_pattern = True
|
||||
|
||||
# Проверяем текст на паттерны согласия
|
||||
combined = label_text.lower()
|
||||
if any(re.search(p, combined, re.UNICODE | re.IGNORECASE) for p in self.CONSENT_PATTERNS):
|
||||
has_consent = True
|
||||
form_has_consent = True
|
||||
|
||||
forms_needing_consent += 1
|
||||
if has_consent:
|
||||
if form_has_consent:
|
||||
forms_with_consent += 1
|
||||
if form_has_dark_pattern:
|
||||
dark_patterns.append({"form_index": total_forms, "issue": "предзаполненный чекбокс согласия"})
|
||||
else:
|
||||
forms_without_consent.append({
|
||||
"form_index": form_idx,
|
||||
"checkboxes": checkbox_texts[:3],
|
||||
})
|
||||
forms_without_consent.append({"reason": "нет текста согласия"})
|
||||
|
||||
if forms_with_consent == forms_needing_consent:
|
||||
# Формируем результат
|
||||
if total_forms == forms_with_consent and not dark_patterns:
|
||||
return self._pass_result(
|
||||
message=f"Все формы ({forms_with_consent}) содержат чекбокс согласия.",
|
||||
details={
|
||||
"forms_count": forms_with_consent,
|
||||
"checked_forms": forms_with_consent,
|
||||
},
|
||||
message=f"Все формы ({forms_with_consent}) содержат чекбокс согласия, dark patterns не найдены.",
|
||||
details={"forms_count": forms_with_consent, "dark_patterns": 0},
|
||||
)
|
||||
|
||||
# Собираем сообщение
|
||||
issues = []
|
||||
if forms_with_consent < total_forms:
|
||||
missing = total_forms - forms_with_consent
|
||||
issues.append(f"{missing} форм без чекбокса согласия")
|
||||
if dark_patterns:
|
||||
issues.append(f"{len(dark_patterns)} предзаполненных чекбоксов (dark pattern)")
|
||||
|
||||
msg = f"Нарушения в формах: {', '.join(issues)}. "
|
||||
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] if f["checkboxes"] else "без чекбоксов" for f in forms_without_consent[:3]],
|
||||
},
|
||||
)
|
||||
msg += "Это прямое нарушение 152-ФЗ."
|
||||
|
||||
# При dark patterns — повышаем риск
|
||||
risk = RiskLevel.CRITICAL if dark_patterns else self.risk_on_fail
|
||||
|
||||
# Частичное покрытие
|
||||
return self._fail_result(
|
||||
message=(
|
||||
f"Только {forms_with_consent} из {forms_needing_consent} форм содержат чекбокс согласия."
|
||||
),
|
||||
message=msg,
|
||||
risk_level=risk,
|
||||
details={
|
||||
"total_forms": forms_needing_consent,
|
||||
"total_forms": total_forms,
|
||||
"with_consent": forms_with_consent,
|
||||
"without_consent_count": forms_needing_consent - forms_with_consent,
|
||||
"without_consent": forms_without_consent,
|
||||
"dark_patterns": dark_patterns,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
"""
|
||||
Опциональный LLM-анализ политики конфиденциальности.
|
||||
Запускается ПОСЛЕ основных проверок, только если явно запрошен.
|
||||
Проверяет 16 обязательных разделов политики по 152-ФЗ.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from scanner.checks.base import BaseCheck
|
||||
from scanner.models import CheckResult, CheckStatus, RiskLevel
|
||||
|
||||
|
||||
# 16 обязательных разделов политики конфиденциальности (Reova, 2026)
|
||||
REQUIRED_SECTIONS = [
|
||||
{"key": "general", "name": "Общие положения", "article": "Ст. 3"},
|
||||
{"key": "operator", "name": "Наименование оператора", "article": "Ст. 18.1"},
|
||||
{"key": "purposes", "name": "Цели обработки", "article": "Ст. 5 ч.2"},
|
||||
{"key": "categories", "name": "Категории ПД", "article": "Ст. 18.1"},
|
||||
{"key": "subjects", "name": "Категории субъектов", "article": "Ст. 18.1"},
|
||||
{"key": "basis", "name": "Правовые основания", "article": "Ст. 6"},
|
||||
{"key": "methods", "name": "Способы обработки", "article": "Ст. 3"},
|
||||
{"key": "storage", "name": "Сроки хранения", "article": "Ст. 5 ч.7"},
|
||||
{"key": "destruction","name": "Порядок уничтожения", "article": "Ст. 21"},
|
||||
{"key": "rights", "name": "Права субъекта", "article": "Ст. 14, 15"},
|
||||
{"key": "security", "name": "Меры безопасности", "article": "Ст. 19"},
|
||||
{"key": "transfer", "name": "Трансграничная передача", "article": "Ст. 12"},
|
||||
{"key": "thirdparty", "name": "Передача третьим лицам", "article": "Ст. 6 ч.3"},
|
||||
{"key": "cookies", "name": "Cookies", "article": "Разъяснения РКН"},
|
||||
{"key": "dpo", "name": "Ответственный за ПД (DPO)", "article": "Ст. 22.1"},
|
||||
{"key": "disputes", "name": "Порядок разрешения споров", "article": "Ст. 17"},
|
||||
]
|
||||
|
||||
|
||||
class LLMPolicyCheck(BaseCheck):
|
||||
"""Глубокий LLM-анализ текста политики конфиденциальности."""
|
||||
|
||||
name = "llm_policy_analysis"
|
||||
title = "LLM-анализ политики конфиденциальности"
|
||||
description = "Проверяет 16 обязательных разделов политики через LLM"
|
||||
risk_on_fail = RiskLevel.HIGH
|
||||
default_weight = 20
|
||||
|
||||
def __init__(self, llm_url: str = "http://192.168.1.152:80/v1/chat/completions",
|
||||
llm_model: str = "Qwen3.6-35B-A3B-UD-IQ4_NL.gguf"):
|
||||
self.llm_url = llm_url
|
||||
self.llm_model = llm_model
|
||||
|
||||
async def check(self, page, url: str) -> CheckResult:
|
||||
"""Этот метод вызывается через API, не через Playwright."""
|
||||
return self._skip_result(message="LLM-анализ запускается отдельно через analyze_policy()")
|
||||
|
||||
async def analyze_policy(self, policy_url: str) -> CheckResult:
|
||||
"""
|
||||
Загрузить страницу политики и проанализировать через LLM.
|
||||
|
||||
Args:
|
||||
policy_url: URL страницы политики конфиденциальности
|
||||
|
||||
Returns:
|
||||
CheckResult с детальным анализом 16 разделов
|
||||
"""
|
||||
try:
|
||||
# 1. Скачиваем текст политики
|
||||
import aiohttp
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(
|
||||
policy_url,
|
||||
headers={"User-Agent": "Mozilla/5.0"},
|
||||
timeout=aiohttp.ClientTimeout(total=10),
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
return self._error_result(f"Не удалось загрузить политику: HTTP {resp.status}")
|
||||
html = await resp.text()
|
||||
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
for tag in soup(["script", "style", "nav", "footer", "header"]):
|
||||
tag.decompose()
|
||||
policy_text = soup.get_text(separator=" ").strip()
|
||||
|
||||
if len(policy_text) < 100:
|
||||
return self._fail_result(
|
||||
message="Текст политики слишком короткий для анализа (<100 символов).",
|
||||
details={"char_count": len(policy_text)},
|
||||
)
|
||||
|
||||
# 2. Отправляем на LLM-анализ
|
||||
sections_list = "\n".join(
|
||||
f"- {s['key']}: {s['name']} ({s['article']})"
|
||||
for s in REQUIRED_SECTIONS
|
||||
)
|
||||
|
||||
system_prompt = (
|
||||
"Ты юридический аудитор. Проанализируй текст политики конфиденциальности "
|
||||
"на соответствие 152-ФЗ. Проверь наличие каждого из 16 обязательных разделов. "
|
||||
"Отвечай СТРОГО в формате JSON без markdown-обёрток."
|
||||
)
|
||||
user_prompt = f"""Проверь, какие из этих разделов присутствуют в тексте политики:
|
||||
|
||||
{sections_list}
|
||||
|
||||
Для каждого раздела укажи:
|
||||
- present: true/false (присутствует ли)
|
||||
- evidence: короткая цитата из текста (до 100 символов), если present=true
|
||||
- quality: "full"/"partial"/"missing"
|
||||
|
||||
Также дай:
|
||||
- overall_score: общая оценка от 0 до 100
|
||||
- summary: краткое резюме на русском (2-3 предложения)
|
||||
|
||||
Текст политики (первые 8000 символов):
|
||||
{policy_text[:8000]}
|
||||
|
||||
Ответь ТОЛЬКО JSON, без markdown:"""
|
||||
|
||||
analysis = await self._call_llm(system_prompt, user_prompt)
|
||||
|
||||
if not analysis:
|
||||
return self._error_result("LLM не вернул результат анализа.")
|
||||
|
||||
# 3. Формируем результат
|
||||
present_count = sum(1 for s in analysis.get("sections", []) if s.get("present"))
|
||||
missing_sections = [s["name"] for s in analysis.get("sections", []) if not s.get("present")]
|
||||
partial_sections = [s["name"] for s in analysis.get("sections", []) if s.get("quality") == "partial"]
|
||||
|
||||
overall = analysis.get("overall_score", 0)
|
||||
summary = analysis.get("summary", "Анализ завершён.")
|
||||
|
||||
if present_count >= 14 and not missing_sections:
|
||||
return self._pass_result(
|
||||
message=f"Политика содержит {present_count}/16 обязательных разделов. {summary}",
|
||||
details={
|
||||
"present_count": present_count,
|
||||
"total_sections": 16,
|
||||
"overall_score": overall,
|
||||
"missing": missing_sections,
|
||||
"partial": partial_sections,
|
||||
"llm_summary": summary,
|
||||
},
|
||||
)
|
||||
|
||||
if present_count >= 10:
|
||||
return self._fail_result(
|
||||
message=f"Политика неполная: {present_count}/16 разделов. Отсутствуют: {', '.join(missing_sections[:5])}. {summary}",
|
||||
risk_level=RiskLevel.HIGH,
|
||||
details={
|
||||
"present_count": present_count,
|
||||
"total_sections": 16,
|
||||
"overall_score": overall,
|
||||
"missing": missing_sections,
|
||||
"partial": partial_sections,
|
||||
"llm_summary": summary,
|
||||
},
|
||||
)
|
||||
|
||||
return self._fail_result(
|
||||
message=f"Политика критически неполная: {present_count}/16 разделов. Отсутствуют: {', '.join(missing_sections[:8])}. {summary}",
|
||||
risk_level=RiskLevel.CRITICAL,
|
||||
details={
|
||||
"present_count": present_count,
|
||||
"total_sections": 16,
|
||||
"overall_score": overall,
|
||||
"missing": missing_sections,
|
||||
"partial": partial_sections,
|
||||
"llm_summary": summary,
|
||||
},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return self._error_result(f"Ошибка LLM-анализа: {str(e)}")
|
||||
|
||||
async def _call_llm(self, system_prompt: str, user_prompt: str) -> dict | None:
|
||||
"""Вызвать LLM для анализа текста политики."""
|
||||
try:
|
||||
import aiohttp
|
||||
payload = {
|
||||
"model": self.llm_model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
"temperature": 0.1,
|
||||
"max_tokens": 2048,
|
||||
}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
self.llm_url,
|
||||
json=payload,
|
||||
timeout=aiohttp.ClientTimeout(total=60),
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
return None
|
||||
data = await resp.json()
|
||||
content = data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||
|
||||
# Извлекаем JSON из ответа (LLM может обернуть в ```json)
|
||||
content = content.strip()
|
||||
if content.startswith("```"):
|
||||
content = re.sub(r"^```(?:json)?\s*", "", content)
|
||||
content = re.sub(r"\s*```$", "", content)
|
||||
|
||||
return json.loads(content)
|
||||
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def run_llm_analysis(policy_url: str, llm_url: str | None = None) -> dict[str, Any]:
|
||||
"""
|
||||
Удобная функция для запуска LLM-анализа из CLI/API.
|
||||
|
||||
Returns:
|
||||
Словарь с результатом check и деталями
|
||||
"""
|
||||
checker = LLMPolicyCheck()
|
||||
if llm_url:
|
||||
checker.llm_url = llm_url
|
||||
|
||||
result = await checker.analyze_policy(policy_url)
|
||||
return {
|
||||
"status": result.status.value,
|
||||
"title": result.title,
|
||||
"message": result.message,
|
||||
"risk_level": result.risk_level.value,
|
||||
"details": result.details,
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
"""
|
||||
Проверка контактных данных оператора: ИНН, ОГРН, DPO.
|
||||
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 OperatorInfoCheck(BaseCheck):
|
||||
"""Проверяет наличие реквизитов оператора и DPO на сайте."""
|
||||
|
||||
name = "operator_info"
|
||||
title = "Контакты оператора и DPO"
|
||||
description = "Проверяет ИНН/ОГРН оператора и контакты ответственного за ПД"
|
||||
risk_on_fail = RiskLevel.HIGH
|
||||
default_weight = 15
|
||||
|
||||
# Regex для российских реквизитов
|
||||
INN_PATTERN = r"ИНН\s*:?\s*(\d{10}|\d{12})"
|
||||
OGRN_PATTERN = r"ОГРН(ИП)?\s*:?\s*(\d{13}|\d{15})"
|
||||
DPO_PATTERNS = [
|
||||
r"ответственн[ыо][ейм]\s*за\s*(обработку\s*)?(персональных?\s*)?данных?",
|
||||
r"(data\s*protection\s*officer|dpo)",
|
||||
r"лиц[ое]\s*ответственн[ао][ея]\s*за\s*пд",
|
||||
]
|
||||
CONTACT_PATTERN = r"([a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+)"
|
||||
|
||||
async def check(self, page: Page, url: str) -> CheckResult:
|
||||
try:
|
||||
# 1. Главная страница
|
||||
page_text = await page.evaluate("() => document.body.innerText")
|
||||
|
||||
# 2. Ищем ссылку на политику конфиденциальности
|
||||
policy_text = ""
|
||||
try:
|
||||
links = await page.query_selector_all("a[href]")
|
||||
policy_patterns = [
|
||||
r"политик[аые]\s*конфиденциальн", r"privacy\s*policy",
|
||||
r"персональные\s*данные", r"обработк[ае]\s*данных",
|
||||
]
|
||||
policy_url = None
|
||||
for link in links:
|
||||
href = await link.get_attribute("href") or ""
|
||||
text = (await link.inner_text()).strip()
|
||||
combined = (text + " " + href).lower()
|
||||
if any(re.search(p, combined, re.UNICODE | re.IGNORECASE) for p in policy_patterns):
|
||||
from urllib.parse import urljoin
|
||||
policy_url = urljoin(url, href)
|
||||
break
|
||||
|
||||
if policy_url:
|
||||
import aiohttp
|
||||
from bs4 import BeautifulSoup
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(
|
||||
policy_url,
|
||||
headers={"User-Agent": "Mozilla/5.0"},
|
||||
timeout=aiohttp.ClientTimeout(total=8),
|
||||
) as resp:
|
||||
if resp.status == 200:
|
||||
html = await resp.text()
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
for tag in soup(["script", "style", "nav", "footer", "header"]):
|
||||
tag.decompose()
|
||||
policy_text = soup.get_text(separator=" ").strip()
|
||||
except Exception:
|
||||
policy_text = "" # Ошибка загрузки политики — работаем с главной
|
||||
|
||||
# 3. Объединяем текст главной + политики
|
||||
combined_text = page_text + "\n" + policy_text
|
||||
|
||||
# 4. Ищем реквизиты
|
||||
inn_match = re.search(self.INN_PATTERN, combined_text, re.IGNORECASE)
|
||||
inn = inn_match.group(1) if inn_match else None
|
||||
|
||||
ogrn_match = re.search(self.OGRN_PATTERN, combined_text, re.IGNORECASE)
|
||||
ogrn = ogrn_match.group(2) if ogrn_match else None
|
||||
|
||||
dpo_found = any(
|
||||
re.search(pattern, combined_text, re.IGNORECASE)
|
||||
for pattern in self.DPO_PATTERNS
|
||||
)
|
||||
|
||||
contacts = re.findall(self.CONTACT_PATTERN, combined_text)
|
||||
contacts = [c for c in contacts if not any(
|
||||
skip in c.lower() for skip in ("example", "test", "noreply", "no-reply")
|
||||
)][:3]
|
||||
|
||||
# 5. Результат
|
||||
has_inn = inn is not None
|
||||
has_ogrn = ogrn is not None
|
||||
has_dpo = dpo_found
|
||||
has_contact = len(contacts) > 0
|
||||
present = sum([has_inn, has_ogrn, has_dpo, has_contact])
|
||||
|
||||
details = {
|
||||
"inn": inn, "ogrn": ogrn,
|
||||
"dpo_found": dpo_found, "contacts": contacts,
|
||||
"policy_checked": bool(policy_text),
|
||||
}
|
||||
|
||||
if present >= 3:
|
||||
return self._pass_result(
|
||||
message=f"Реквизиты оператора: ИНН={'есть' if has_inn else 'нет'}, "
|
||||
f"ОГРН={'есть' if has_ogrn else 'нет'}, "
|
||||
f"DPO={'есть' if has_dpo else 'нет'}",
|
||||
details=details,
|
||||
)
|
||||
|
||||
if present >= 1:
|
||||
missing = []
|
||||
if not has_inn: missing.append("ИНН")
|
||||
if not has_ogrn: missing.append("ОГРН")
|
||||
if not has_dpo: missing.append("контакты DPO")
|
||||
return self._fail_result(
|
||||
message=f"Реквизиты неполные: отсутствуют {', '.join(missing)}",
|
||||
risk_level=RiskLevel.HIGH,
|
||||
details=details,
|
||||
)
|
||||
|
||||
return self._fail_result(
|
||||
message="Реквизиты оператора не найдены. Отсутствуют ИНН, ОГРН, DPO и контакты.",
|
||||
risk_level=RiskLevel.CRITICAL,
|
||||
details=details,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return self._error_result(f"Ошибка проверки реквизитов: {str(e)}")
|
||||
@@ -60,15 +60,15 @@ class PrivacyLinkCheck(BaseCheck):
|
||||
details={"total_links": len(link_texts), "page_text_sample": link_texts[:3]},
|
||||
)
|
||||
|
||||
# Проверяем, что ссылка ведет на рабочую страницу
|
||||
# Проверяем доступность страницы политики через aiohttp (не page.goto — гонка данных!)
|
||||
policy_url = found_links[0]["url"]
|
||||
try:
|
||||
# Проверяем, не является ли URL документом (PDF, DOCX, XLSX)
|
||||
import aiohttp
|
||||
|
||||
doc_extensions = ['.pdf', '.docx', '.doc', '.xlsx', '.xls', '.rtf', '.txt']
|
||||
is_document = any(policy_url.lower().endswith(ext) for ext in doc_extensions)
|
||||
|
||||
if is_document:
|
||||
# Документ для скачивания — это допустимо для политики
|
||||
return self._pass_result(
|
||||
message=f"Найдена ссылка на Политику конфиденциальности ({policy_url})",
|
||||
details={
|
||||
@@ -77,18 +77,30 @@ class PrivacyLinkCheck(BaseCheck):
|
||||
"is_document": True,
|
||||
},
|
||||
)
|
||||
|
||||
# Для HTML-страниц — проверяем доступность
|
||||
await page.goto(policy_url, timeout=5000)
|
||||
policy_text = await page.evaluate("() => document.body.innerText")
|
||||
await page.goto(url, timeout=5000) # Возвращаемся на главную
|
||||
|
||||
|
||||
# Для HTML-страниц — проверяем через aiohttp (не меняет URL страницы!)
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(policy_url, timeout=aiohttp.ClientTimeout(total=8)) as resp:
|
||||
if resp.status != 200:
|
||||
return self._fail_result(
|
||||
message=f"Страница политики недоступна (HTTP {resp.status}): {policy_url}",
|
||||
details={"policy_url": policy_url, "http_status": resp.status},
|
||||
)
|
||||
html = await resp.text()
|
||||
|
||||
# Извлекаем текст из HTML
|
||||
from bs4 import BeautifulSoup
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
for tag in soup(["script", "style", "nav", "footer", "header"]):
|
||||
tag.decompose()
|
||||
policy_text = soup.get_text(separator=" ").strip()
|
||||
|
||||
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={
|
||||
@@ -97,7 +109,7 @@ class PrivacyLinkCheck(BaseCheck):
|
||||
"char_count": len(policy_text),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
return self._fail_result(
|
||||
message=f"Страница политики не доступна: {str(e)}",
|
||||
|
||||
@@ -79,39 +79,34 @@ class ServerLocationCheck(BaseCheck):
|
||||
return self._error_result(message=f"Ошибка при проверке геолокации: {str(e)}")
|
||||
|
||||
async def _get_country(self, ip: str) -> str | None:
|
||||
"""Получает страну по IP через публичный API."""
|
||||
"""Получает страну по IP через публичный API (асинхронно)."""
|
||||
try:
|
||||
import urllib.request
|
||||
import json
|
||||
import aiohttp
|
||||
|
||||
# Пробуем несколько бесплатных 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
|
||||
async with aiohttp.ClientSession() as session:
|
||||
for api_url in apis:
|
||||
try:
|
||||
async with session.get(
|
||||
api_url,
|
||||
headers={"User-Agent": "Mozilla/5.0"},
|
||||
timeout=aiohttp.ClientTimeout(total=5),
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
continue
|
||||
data = await response.json()
|
||||
# ipapi.co формат
|
||||
if "country_name" in data:
|
||||
return data["country_name"]
|
||||
# ip-api.com формат
|
||||
if "country" in data:
|
||||
return data["country"]
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
|
||||
Reference in new issue
Block a user