Files
neo b9db935389 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)
2026-06-10 00:49:07 +05:00

229 lines
9.0 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Точка входа для 152-ФЗ Scanner.
Поддерживает запуск через CLI (один URL или CSV) и через API-сервер.
"""
import asyncio
import csv
import json
import sys
from pathlib import Path
from scanner.engine import ScannerEngine
from scanner.models import SiteScan
from scanner.output import out
from reports.generator import PDFReportGenerator
async def scan_single(url: str, checks: list[str] | None = None, deep: bool = False) -> SiteScan:
"""Просканировать один URL и вывести результат.
Args:
url: URL для сканирования
checks: список проверок (None = QUICK_CHECKS)
deep: запустить LLM-анализ политики после основных проверок
"""
engine = ScannerEngine()
result = await engine.scan_url(url, checks)
# Выводим результаты в терминал
out(f"\n{'='*60}")
out(f"🔍 Сканирование: {url}")
out(f"{'='*60}")
out(f"📊 Балл: {result.score}/100")
out(f"{'✅ Соответствует' if result.is_compliant else '❌ НЕ соответствует'} 152-ФЗ")
out(f"📝 {result.summary}")
out(f"\n{'─'*40}")
for check in result.checks:
emoji = {
"pass": "✅",
"fail": "❌",
"skip": "⏭",
"error": "⚠️",
}.get(check.status.value, " ")
out(f"{emoji} {check.title}: {check.message}")
out(f"{'='*60}\n")
# Опциональный LLM-анализ политики
if deep:
out("🤖 Запуск глубокого LLM-анализа политики...")
try:
from scanner.checks.llm_policy import run_llm_analysis
# Ищем URL политики в результатах проверок
policy_url = None
for check in result.checks:
if check.name == "privacy_link" and check.status.value == "pass":
policy_url = check.details.get("policy_url")
break
if policy_url:
llm_result = await run_llm_analysis(policy_url)
out(f"\n{'─'*40}")
out(f"🧠 {llm_result['title']}")
out(f" Статус: {llm_result['status']}")
out(f" Риск: {llm_result['risk_level']}")
out(f" {llm_result['message']}")
if llm_result.get('details', {}).get('present_count'):
d = llm_result['details']
out(f" Разделов: {d['present_count']}/16")
if d.get('missing'):
out(f" Отсутствуют: {', '.join(d['missing'][:5])}")
else:
out(" ⚠️ URL политики не найден — LLM-анализ пропущен")
except Exception as e:
out(f" ⚠️ Ошибка LLM-анализа: {e}")
return result
def is_likely_hotel(url: str) -> bool:
"""Проверяет, похож ли URL на сайт отеля (фильтр не-отелей)."""
domain = url.replace("https://", "").replace("http://", "").split("/")[0].lower()
# Известные не-отели — пропускаем их
not_hotel_patterns = [
"wikipedia", "google", "example", "demo", "test", "toscrape",
"herokuapp", "phpstore", "myshopify", "wordpress", "joomla",
"drupal", "typo3", "prestashop", "magento", "opencart",
"wix", "squarespace", "webflow", "shopify", "woocommerce",
"tilda", "constructor", "builder", "playwright", "node",
"localhost", "127.0.0.1", "ip-api", "ipify", "my-ip",
"reg.ru", "nic.ru", "beget", "timeweb", "selectel",
]
for pattern in not_hotel_patterns:
if pattern in domain:
return False
# Разрешаем всё остальное — пользователь курирует список
return True
def scan_csv(filepath: str, checks: list[str] | None = None):
"""Парксить CSV-файл с URL и запустить сканирование."""
csv_path = Path(filepath)
if not csv_path.exists():
out(f"❌ Файл не найден: {filepath}")
sys.exit(1)
urls = []
skipped = []
with open(csv_path, "r", encoding="utf-8") as f:
reader = csv.reader(f)
for row in reader:
if row:
url = row[0].strip()
if url and url.startswith(("http://", "https://")):
if is_likely_hotel(url):
urls.append(url)
else:
skipped.append(url)
if skipped:
out(f"⚠️ Пропущено {len(skipped)} URL (не похожи на отели):")
for u in skipped[:5]:
out(f" {u}")
if len(skipped) > 5:
out(f" ...и ещё {len(skipped) - 5}")
out()
if not urls:
out("❌ В CSV не найдено валидных URL")
sys.exit(1)
out(f"📋 Загружено {len(urls)} URL из {csv_path}")
async def batch_scan():
engine = ScannerEngine()
results = await engine.scan_urls_batch(urls, checks)
output = []
for r in results:
output.append({
"url": r.url,
"score": r.score,
"is_compliant": r.is_compliant,
"summary": r.summary,
"checks": [c.model_dump() for c in r.checks],
})
# Сохраняем JSON
output_dir = Path(__file__).parent / "data" / "output"
output_dir.mkdir(parents=True, exist_ok=True)
json_path = output_dir / "results.json"
with open(json_path, "w", encoding="utf-8") as f:
json.dump(output, f, ensure_ascii=False, indent=2)
out(f"\n📄 Результаты сохранены в: {json_path}")
# Генерируем PDF для каждого сайта
generator = PDFReportGenerator()
reports_dir = output_dir / "reports"
for result in output:
try:
pdf_path = generator.generate(result, str(reports_dir))
out(f" 📄 PDF: {pdf_path}")
except Exception as e:
out(f" ⚠️ Ошибка генерации PDF для {result['url']}: {e}")
# Сводка
compliant = sum(1 for r in output if r["is_compliant"])
non_compliant = len(output) - compliant
out(f"\n📊 Сводка:")
out(f" Всего сайтов: {len(output)}")
out(f" ✅ Соответствуют: {compliant}")
out(f" ❌ НЕ соответствуют: {non_compliant}")
# Показываем топ нарушений
out(f"\n🔥 Топ нарушений:")
check_failures = {}
for r in output:
for check in r["checks"]:
if check["status"] == "fail":
name = check["title"]
check_failures[name] = check_failures.get(name, 0) + 1
for name, count in sorted(check_failures.items(), key=lambda x: -x[1]):
out(f" ❌ {name}: {count} сайтов")
asyncio.run(batch_scan())
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="152-ФЗ Scanner")
subparsers = parser.add_subparsers(dest="command")
# Подкоманда: single
single_parser = subparsers.add_parser("scan", help="Сканировать один URL")
single_parser.add_argument("url", help="URL сайта для сканирования")
single_parser.add_argument("--checks", nargs="+", help="Какие проверки запустить")
single_parser.add_argument("--deep", action="store_true", help="Запустить LLM-анализ политики после сканирования")
# Подкоманда: csv
csv_parser = subparsers.add_parser("csv", help="Сканировать CSV с URL")
csv_parser.add_argument("filepath", help="Путь к CSV-файлу")
csv_parser.add_argument("--checks", nargs="+", help="Какие проверки запустить")
# Подкоманда: server
server_parser = subparsers.add_parser("server", help="Запустить API-сервер")
server_parser.add_argument("--port", type=int, default=8000, help="Порт (по умолчанию 8000)")
server_parser.add_argument("--host", default="0.0.0.0", help="Хост (по умолчанию 0.0.0.0)")
args = parser.parse_args()
if args.command == "scan":
asyncio.run(scan_single(args.url, args.checks, args.deep))
elif args.command == "csv":
scan_csv(args.filepath, args.checks)
elif args.command == "server":
import uvicorn
uvicorn.run("api.server:app", host=args.host, port=args.port, reload=True)
else:
parser.print_help()