Скрипт для парсинга KYC-сессий

#!/usr/bin/env python3
"""
Выгрузка KYC-сессий через REST API NeuroVision.

Авторизуется по email/паролю, получает JWT, затем постранично выгружает
сессии через POST /v1/kyc/sessions/filter. Данные пишутся на диск чанками,
выгрузку можно прервать и возобновить с того же места (файл состояния).

Примеры:
  # Все сессии за период
  python3 fetch_sessions.py --email admin@example.com --password secret \\
      --admin-user-id 4 --start-date 2025-12-01 --end-date 2026-03-10

  # Конкретный сценарий, отдельный файл состояния
  python3 fetch_sessions.py --email admin@example.com --password secret \\
      --admin-user-id 4 --start-date 2025-12-01 \\
      --schema-id db3c8340-8a44-11ef-86ed-a1c315ebff3c \\
      --state-file sessions_client.pkl

  # Фильтр по статусу / clientKey
  python3 fetch_sessions.py --email admin@example.com --password secret \\
      --start-date 2025-01-01 --status failed
  python3 fetch_sessions.py --email admin@example.com --password secret \\
      --start-date 2025-01-01 --client-key user@example.com
"""

from __future__ import annotations

import argparse
import logging
import os
import pickle
import signal
import sys
import time
from dataclasses import dataclass, field
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Iterator

import requests

# --- Константы по умолчанию ---------------------------------------------------

DEFAULT_API_BASE = "https://api.neuro-vision.ru"
DEFAULT_STATE_FILE = "sessions.pkl"
DEFAULT_PAGE_SIZE = 100
DEFAULT_WINDOW_DAYS = 365
DEFAULT_MAX_RETRIES = 5
DEFAULT_CHUNK_SIZE = 10_000

LOGIN_TIMEOUT = 30
FETCH_TIMEOUT = 60
TOKEN_VALID_DAYS = 5
SAVE_STATE_EVERY_PAGES = 10

STATUSES = ["idle", "processing", "success", "failed",
            "exception", "suspicious", "expired"]

log = logging.getLogger("fetch_sessions")


# --- CLI ----------------------------------------------------------------------

def parse_args() -> argparse.Namespace:
    p = argparse.ArgumentParser(
        description="Выгрузка KYC-сессий через REST API",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    p.add_argument("--email", required=True, help="Email для логина")
    p.add_argument("--password", required=True, help="Пароль")
    p.add_argument("--admin-user-id", default="", help="ID администратора (заголовок admin-user-id)")
    p.add_argument("--api-base", default=DEFAULT_API_BASE, help="Базовый URL API")
    p.add_argument("--start-date", required=True, help="Дата начала (YYYY-MM-DD)")
    p.add_argument("--end-date", default=None, help="Дата конца (YYYY-MM-DD), по умолч. сегодня")
    p.add_argument("--schema-id", default=None, help="Фильтр по schemaId")
    p.add_argument("--status", default=None, choices=STATUSES, help="Фильтр по статусу сессии")
    p.add_argument("--client-key", default=None, help="Фильтр по clientKey/clientUser")
    p.add_argument("--state-file", default=DEFAULT_STATE_FILE, help="Файл состояния")
    p.add_argument("--page-size", type=int, default=DEFAULT_PAGE_SIZE)
    p.add_argument("--window-days", type=int, default=DEFAULT_WINDOW_DAYS)
    p.add_argument("--max-retries", type=int, default=DEFAULT_MAX_RETRIES)
    p.add_argument("--chunk-size", type=int, default=DEFAULT_CHUNK_SIZE)
    return p.parse_args()


def parse_date(value: str) -> date:
    try:
        return datetime.strptime(value, "%Y-%m-%d").date()
    except ValueError:
        sys.exit(f"Неверный формат даты: {value!r} (ожидается YYYY-MM-DD)")


# --- Окна дат -----------------------------------------------------------------

def generate_windows(start: date, end: date, window_days: int) -> list[tuple[date, date]]:
    windows, current = [], start
    while current <= end:
        window_end = min(current + timedelta(days=window_days), end)
        windows.append((current, window_end))
        current = window_end + timedelta(days=1)
    return windows


def fmt_start(d: date) -> str:
    return d.strftime("%Y-%m-%dT00:00:00.000Z")


def fmt_end(d: date) -> str:
    return d.strftime("%Y-%m-%dT23:59:59.999Z")


# --- Хранилище с чанками и возобновлением -------------------------------------

@dataclass
class ChunkedStorage:
    """Буферизует сессии в памяти и сбрасывает на диск чанками фиксированного
    размера. Хранит позицию пагинации (окно + страница) для возобновления."""

    state_file: str
    chunk_size: int
    chunk_dir: Path = field(init=False)
    state_path: Path = field(init=False)
    buffer: list[Any] = field(default_factory=list)
    total_saved: int = 0
    chunk_idx: int = 0
    window_idx: int = 0
    page: int = 1

    def __post_init__(self) -> None:
        base = self.state_file.removesuffix(".pkl")
        self.chunk_dir = Path(f"{base}_chunks")
        self.chunk_dir.mkdir(exist_ok=True)
        self.state_path = self.chunk_dir / "_state.pkl"

    @property
    def total_count(self) -> int:
        return self.total_saved + len(self.buffer)

    def load(self) -> bool:
        if not self.state_path.exists():
            return False
        with open(self.state_path, "rb") as f:
            state = pickle.load(f)
        self.__dict__.update(
            chunk_idx=state["chunk_idx"],
            total_saved=state["total_saved"],
            buffer=state["buffer"],
            window_idx=state["window_idx"],
            page=state["page"],
        )
        log.info("Состояние загружено: %s записей (%s чанков + %s в буфере)",
                 f"{self.total_count:,}", self.chunk_idx, len(self.buffer))
        return True

    def add(self, sessions: list[Any]) -> None:
        self.buffer.extend(sessions)
        while len(self.buffer) >= self.chunk_size:
            self._save_chunk(self.buffer[:self.chunk_size])
            del self.buffer[:self.chunk_size]

    def flush(self) -> None:
        if self.buffer:
            self._save_chunk(self.buffer)
            self.buffer = []

    def _save_chunk(self, chunk: list[Any]) -> None:
        path = self.chunk_dir / f"chunk_{self.chunk_idx:04d}.pkl"
        self._atomic_dump(chunk, path)
        self.total_saved += len(chunk)
        self.chunk_idx += 1
        log.info("  Чанк %s сохранён (%s записей, всего: %s)",
                 self.chunk_idx, f"{len(chunk):,}", f"{self.total_count:,}")

    def save_state(self, window_idx: int, page: int) -> None:
        self.window_idx, self.page = window_idx, page
        self._atomic_dump(
            {
                "chunk_idx": self.chunk_idx,
                "total_saved": self.total_saved,
                "buffer": self.buffer,
                "window_idx": window_idx,
                "page": page,
            },
            self.state_path,
        )

    @staticmethod
    def _atomic_dump(obj: Any, path: Path) -> None:
        tmp = path.with_suffix(path.suffix + ".tmp")
        with open(tmp, "wb") as f:
            pickle.dump(obj, f)
        os.replace(tmp, path)


# --- HTTP-клиент --------------------------------------------------------------

class ApiClient:
    def __init__(self, api_base: str, max_retries: int):
        self.api_base = api_base.rstrip("/")
        self.max_retries = max_retries
        self.session = requests.Session()
        self.token: str | None = None
        self.admin_user_id = ""

    def close(self) -> None:
        self.session.close()

    def login(self, email: str, password: str) -> None:
        url = f"{self.api_base}/v1/user/login"
        log.info("Авторизация: %s -> %s", email, url)
        resp = self.session.post(
            url,
            json={"email": email, "password": password, "valid_days": TOKEN_VALID_DAYS},
            timeout=LOGIN_TIMEOUT,
        )
        if resp.status_code != 200:
            sys.exit(f"Ошибка логина: HTTP {resp.status_code}\n{resp.text[:500]}")

        data = resp.json()
        token = data.get("token") or data.get("jwt") or data.get("accessToken")
        if not token:
            sys.exit(f"JWT не найден в ответе. Ключи: {list(data.keys())}")

        self.token = token
        log.info("JWT получен (валиден %s дн.)", TOKEN_VALID_DAYS)

    def fetch_page(self, range_start: str, range_end: str, page: int, page_size: int,
                   *, schema_id=None, status=None, client_key=None) -> dict | None:
        """Возвращает results или None при API-ошибке. Бросает при сетевом фейле."""
        url = f"{self.api_base}/v1/kyc/sessions/filter"
        headers = {
            "accept": "application/json",
            "content-type": "application/json",
            "token": self.token,
        }
        if self.admin_user_id:
            headers["admin-user-id"] = self.admin_user_id

        filters: dict[str, Any] = {
            "range": {"start": range_start, "end": range_end, "sortOrder": "DESC"},
            "groupByClientId": "all",
        }
        if schema_id:
            filters["schemaId"] = schema_id
        if status:
            filters["status"] = status
        if client_key:
            filters["clientKey"] = client_key

        payload = {"page": page, "pageSize": page_size, "filters": filters}

        for attempt in range(1, self.max_retries + 1):
            try:
                resp = self.session.post(url, json=payload, headers=headers, timeout=FETCH_TIMEOUT)
                if resp.status_code == 200:
                    body = resp.json()
                    if body.get("status") != "ok":
                        log.error("  API error: %s", body)
                        return None
                    return body["results"]
                log.warning("  HTTP %s: %s", resp.status_code, resp.text[:200])
            except requests.exceptions.RequestException as e:
                log.warning("  Сетевая ошибка (попытка %s/%s): %s", attempt, self.max_retries, e)

            delay = 2 ** (attempt - 1)
            log.info("  Повтор через %sс...", delay)
            time.sleep(delay)

        raise RuntimeError(f"Не удалось получить данные после {self.max_retries} попыток")


# --- Graceful shutdown --------------------------------------------------------

class ShutdownFlag:
    def __init__(self) -> None:
        self.requested = False
        signal.signal(signal.SIGINT, self._handle)
        signal.signal(signal.SIGTERM, self._handle)

    def _handle(self, signum, frame) -> None:
        if self.requested:
            log.warning("Повторный сигнал — немедленный выход.")
            sys.exit(1)
        self.requested = True
        log.info("Завершаем после текущего запроса...")


# --- Основной цикл ------------------------------------------------------------

def run(args: argparse.Namespace) -> None:
    start_date = parse_date(args.start_date)
    end_date = parse_date(args.end_date) if args.end_date else datetime.now(timezone.utc).date()
    if start_date > end_date:
        sys.exit(f"start-date ({start_date}) позже end-date ({end_date})")

    windows = generate_windows(start_date, end_date, args.window_days)
    log.info("Период: %s — %s (%s окон по %s дн.)",
             start_date, end_date, len(windows), args.window_days)

    client = ApiClient(args.api_base, args.max_retries)
    client.login(args.email, args.password)
    client.admin_user_id = args.admin_user_id

    storage = ChunkedStorage(args.state_file, args.chunk_size)
    if not storage.load():
        storage.window_idx, storage.page = 0, 1

    shutdown = ShutdownFlag()
    start_time = time.time()
    records_at_start = storage.total_count

    window_idx, page = storage.window_idx, storage.page

    try:
        while window_idx < len(windows) and not shutdown.requested:
            w_start, w_end = windows[window_idx]
            range_start, range_end = fmt_start(w_start), fmt_end(w_end)

            if page == 1:
                log.info("[Окно %s/%s] %s — %s", window_idx + 1, len(windows), w_start, w_end)

            while not shutdown.requested:
                result = client.fetch_page(
                    range_start, range_end, page, args.page_size,
                    schema_id=args.schema_id, status=args.status, client_key=args.client_key,
                )
                if result is None:
                    break

                sessions = result.get("sessions", [])
                page_info = result.get("pageInfo", {})
                total_items = page_info.get("totalItems", "?")
                total_pages = page_info.get("total", 0) or 0

                if not sessions:
                    log.info("  Страница %s пуста — окно завершено.", page)
                    break

                storage.add(sessions)

                elapsed = time.time() - start_time
                new_records = storage.total_count - records_at_start
                speed = new_records / elapsed if elapsed > 0 else 0
                log.info("  стр. %s/%s | +%s | окно: %s сессий | всего: %s | %.0f зап/с",
                         page, total_pages or "?", len(sessions), total_items,
                         f"{storage.total_count:,}", speed)

                if total_pages and page >= total_pages:
                    break

                page += 1
                if page % SAVE_STATE_EVERY_PAGES == 0:
                    storage.save_state(window_idx, page)

            window_idx += 1
            page = 1
            storage.save_state(window_idx, page)

    except Exception as e:
        log.exception("ОШИБКА: %s", e)
    finally:
        storage.flush()
        storage.save_state(window_idx, page)
        elapsed = time.time() - start_time
        new_records = storage.total_count - records_at_start
        log.info("Итого: %s записей (+%s за сессию, %.0fс)",
                 f"{storage.total_count:,}", f"{new_records:,}", elapsed)
        log.info("Чанки: %s/", storage.chunk_dir)
        client.close()


def main() -> None:
    logging.basicConfig(
        level=logging.INFO,
        format="%(asctime)s %(levelname)s %(message)s",
        datefmt="%H:%M:%S",
    )
    run(parse_args())


if __name__ == "__main__":
    main()