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:
neo committed 2026-06-10 00:49:07 +05:00
1 parent 7fb417dad5
commit b9db935389
24 files changed
+1725 -286

No files matched your search

+71 -31
View File
@@ -10,22 +10,29 @@ 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) -> SiteScan:
"""Просканировать один URL и вывести результат."""
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)
# Выводим результаты в терминал
print(f"\n{'='*60}")
print(f"🔍 Сканирование: {url}")
print(f"{'='*60}")
print(f"📊 Балл: {result.score}/100")
print(f"{'✅ Соответствует' if result.is_compliant else '❌ НЕ соответствует'} 152-ФЗ")
print(f"📝 {result.summary}")
print(f"\n{'─'*40}")
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 = {
@@ -34,9 +41,40 @@ async def scan_single(url: str, checks: list[str] | None = None) -> SiteScan:
"skip": "⏭",
"error": "⚠️",
}.get(check.status.value, " ")
print(f"{emoji} {check.title}: {check.message}")
out(f"{emoji} {check.title}: {check.message}")
print(f"{'='*60}\n")
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
@@ -63,11 +101,11 @@ def is_likely_hotel(url: str) -> bool:
return True
def scan_csv(filepath: str):
def scan_csv(filepath: str, checks: list[str] | None = None):
"""Парксить CSV-файл с URL и запустить сканирование."""
csv_path = Path(filepath)
if not csv_path.exists():
print(f"❌ Файл не найден: {filepath}")
out(f"❌ Файл не найден: {filepath}")
sys.exit(1)
urls = []
@@ -84,22 +122,22 @@ def scan_csv(filepath: str):
skipped.append(url)
if skipped:
print(f"⚠️ Пропущено {len(skipped)} URL (не похожи на отели):")
out(f"⚠️ Пропущено {len(skipped)} URL (не похожи на отели):")
for u in skipped[:5]:
print(f" {u}")
out(f" {u}")
if len(skipped) > 5:
print(f" ...и ещё {len(skipped) - 5}")
print()
out(f" ...и ещё {len(skipped) - 5}")
out()
if not urls:
print("❌ В CSV не найдено валидных URL")
out("❌ В CSV не найдено валидных URL")
sys.exit(1)
print(f"📋 Загружено {len(urls)} URL из {csv_path}")
out(f"📋 Загружено {len(urls)} URL из {csv_path}")
async def batch_scan():
engine = ScannerEngine()
results = await engine.scan_urls_batch(urls)
results = await engine.scan_urls_batch(urls, checks)
output = []
for r in results:
@@ -119,7 +157,7 @@ def scan_csv(filepath: str):
with open(json_path, "w", encoding="utf-8") as f:
json.dump(output, f, ensure_ascii=False, indent=2)
print(f"\n📄 Результаты сохранены в: {json_path}")
out(f"\n📄 Результаты сохранены в: {json_path}")
# Генерируем PDF для каждого сайта
generator = PDFReportGenerator()
@@ -128,20 +166,20 @@ def scan_csv(filepath: str):
for result in output:
try:
pdf_path = generator.generate(result, str(reports_dir))
print(f" 📄 PDF: {pdf_path}")
out(f" 📄 PDF: {pdf_path}")
except Exception as e:
print(f" ⚠️ Ошибка генерации PDF для {result['url']}: {e}")
out(f" ⚠️ Ошибка генерации PDF для {result['url']}: {e}")
# Сводка
compliant = sum(1 for r in output if r["is_compliant"])
non_compliant = len(output) - compliant
print(f"\n📊 Сводка:")
print(f" Всего сайтов: {len(output)}")
print(f" ✅ Соответствуют: {compliant}")
print(f" ❌ НЕ соответствуют: {non_compliant}")
out(f"\n📊 Сводка:")
out(f" Всего сайтов: {len(output)}")
out(f" ✅ Соответствуют: {compliant}")
out(f" ❌ НЕ соответствуют: {non_compliant}")
# Показываем топ нарушений
print(f"\n🔥 Топ нарушений:")
out(f"\n🔥 Топ нарушений:")
check_failures = {}
for r in output:
for check in r["checks"]:
@@ -150,7 +188,7 @@ def scan_csv(filepath: str):
check_failures[name] = check_failures.get(name, 0) + 1
for name, count in sorted(check_failures.items(), key=lambda x: -x[1]):
print(f" ❌ {name}: {count} сайтов")
out(f" ❌ {name}: {count} сайтов")
asyncio.run(batch_scan())
@@ -165,10 +203,12 @@ if __name__ == "__main__":
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-сервер")
@@ -178,9 +218,9 @@ if __name__ == "__main__":
args = parser.parse_args()
if args.command == "scan":
asyncio.run(scan_single(args.url, args.checks))
asyncio.run(scan_single(args.url, args.checks, args.deep))
elif args.command == "csv":
scan_csv(args.filepath)
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)