From 9fc382ae28fbdf44a8a0c06061afe309cb62f948 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 15 Jun 2026 16:58:29 +0300 Subject: [PATCH] Add web export bundle and cookie header parsing --- .dockerignore | 7 + .gitignore | 5 +- ...er-compose.yml_ Compose Deployment.run.xml | 11 + Dockerfile | 15 + README.md | 64 +++ docker-compose.yml | 26 + load_data.py | 27 + netscape_cookies/README.md | 6 + netscape_cookies/parse_netscape_cookies.py | 2 + web_app.py | 486 ++++++++++++++++++ 10 files changed, 648 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 .run/docker-compose.yml_ Compose Deployment.run.xml create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 web_app.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b117725 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.git +.idea +.venv +__pycache__/ +*.py[cod] +.DS_Store +data/ diff --git a/.gitignore b/.gitignore index 3d031da..4f2c92c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ .idea -data \ No newline at end of file +data +__pycache__/ +*.py[cod] +.DS_Store diff --git a/.run/docker-compose.yml_ Compose Deployment.run.xml b/.run/docker-compose.yml_ Compose Deployment.run.xml new file mode 100644 index 0000000..ca3cba8 --- /dev/null +++ b/.run/docker-compose.yml_ Compose Deployment.run.xml @@ -0,0 +1,11 @@ + + + + + + + + + \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..58a92ef --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.13-slim + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8000 + +CMD ["python", "web_app.py", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md index d41ff21..55c1309 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,70 @@ output_path = exporter.save_month(2026, 5, "sber_may_2026.xlsx") print(output_path) ``` +## Веб-интерфейс + +Запустить локальную форму: + +```bash +.venv/bin/python web_app.py +``` + +После запуска откройте: + +```text +http://127.0.0.1:8000 +``` + +Публичный адрес через Traefik: + +```text +https://sber.gb.7af.ru/ +``` + +В форме можно вставить cookies в одном из поддержанных форматов, формат определяется автоматически: + +- Netscape `cookies.txt` +- JSON-массив cookies +- обычный HTTP-заголовок `Cookie` + +Для получения `cookies.txt` можно использовать расширение Chrome: +[Get cookies.txt LOCALLY](https://chromewebstore.google.com/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc?pli=1). + +Порядок действий: + +1. Установить расширение [Get cookies.txt LOCALLY](https://chromewebstore.google.com/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc?pli=1). +2. Открыть [СберБанк Онлайн](https://online.sberbank.ru/CSAFront/index.do) и авторизоваться. +3. Скопировать cookies через расширение и вставить их в поле формы. + +После вставки cookies форма сразу проверяет, сможет ли сервер распарсить введенный формат. + +После выбора месяца экспорт скачивается ZIP-архивом. Внутри архива: + +- `.xlsx` с основными полями операций +- `.json` со списком всех операций в том виде, в котором они пришли из API + +Каждый экспорт сохраняется в `data/exports` с уникальным именем ZIP-архива. После скачивания архив не удаляется; отдельные временные `.xlsx` и `.json` остаются только внутри архива. + +### Запуск через Docker Compose + +```bash +docker compose up --build +``` + +После запуска интерфейс будет доступен здесь: + +```text +http://127.0.0.1:8000 +``` + +В Docker файлы экспорта сохраняются в named volume `sberhistorytoexcel_sber-history-data`. + +Остановить контейнер: + +```bash +docker compose down +``` + Можно отдельно получить операции без сохранения: ```python diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4a8bb41 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,26 @@ +services: + sber-history-web: + build: . + labels: + - "traefik.enable=true" + - "traefik.docker.network=traefik" + - "traefik.http.routers.sber-history-web.rule=Host(`sber.gb.7af.ru`)" + - "traefik.http.routers.sber-history-web.entrypoints=websecure" + - "traefik.http.routers.sber-history-web.tls=true" + - "traefik.http.routers.sber-history-web.tls.certresolver=letsencrypt" + - "traefik.http.services.sber-history-web.loadbalancer.server.port=8000" + - "traefik.http.routers.sber-history-web.service=sber-history-web" + networks: + - traefik + ports: + - "8000:8000" + volumes: + - sber-history-data:/app/data + restart: unless-stopped + +networks: + traefik: + external: true + +volumes: + sber-history-data: diff --git a/load_data.py b/load_data.py index 2804217..251c558 100644 --- a/load_data.py +++ b/load_data.py @@ -1,6 +1,7 @@ from __future__ import annotations import argparse +import json import os import zipfile from dataclasses import dataclass, field @@ -80,6 +81,24 @@ class SberMonthHistoryExporter: self._write_xlsx(path, rows) return path + def save_month_bundle( + self, + year: int, + month: int, + xlsx_path: str | Path | None = None, + json_path: str | Path | None = None, + ) -> tuple[Path, Path, int]: + """Fetch a month and save Excel rows plus the raw operation list as JSON.""" + operations = self.fetch_month(year, month) + xlsx_output = Path(xlsx_path or f"sber_operations_{year}_{month:02d}.xlsx") + json_output = Path(json_path or f"sber_operations_{year}_{month:02d}.json") + + rows = [self._operation_to_row(operation) for operation in operations] + self._write_xlsx(xlsx_output, rows) + self._write_json(json_output, operations) + + return xlsx_output, json_output, len(operations) + def _fetch_page(self, offset: int, month_start: datetime) -> list[dict[str, Any]]: payload = { "paginationOffset": offset, @@ -230,6 +249,14 @@ class SberMonthHistoryExporter: archive.writestr("xl/styles.xml", _styles_xml()) archive.writestr("xl/worksheets/sheet1.xml", worksheet_xml) + @staticmethod + def _write_json(path: Path, operations: Iterable[dict[str, Any]]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps(list(operations), ensure_ascii=False, indent=2), + encoding="utf-8", + ) + def _build_worksheet_xml(headers: list[str], rows: list[dict[str, Any]]) -> str: sheet_rows = [_build_row_xml(1, headers)] diff --git a/netscape_cookies/README.md b/netscape_cookies/README.md index 3839f14..3ab6dcc 100644 --- a/netscape_cookies/README.md +++ b/netscape_cookies/README.md @@ -50,6 +50,12 @@ session_id=abc123; theme=dark; ``` +Также можно вставить заголовок целиком с префиксом: + +```text +Cookie: session_id=abc123; theme=dark; +``` + У этого формата нет метаданных вроде `domain`, `expires`, `secure` и `httpOnly`, поэтому скрипт заполняет их пустыми или безопасными значениями по умолчанию. ## Использование diff --git a/netscape_cookies/parse_netscape_cookies.py b/netscape_cookies/parse_netscape_cookies.py index 79228f2..ef7b7d2 100644 --- a/netscape_cookies/parse_netscape_cookies.py +++ b/netscape_cookies/parse_netscape_cookies.py @@ -157,6 +157,8 @@ def parse_json_cookies(text: str, *, decode_value: bool = False) -> list[Netscap def parse_cookie_header(text: str, *, decode_value: bool = False) -> list[NetscapeCookie]: header = text.strip().rstrip(";") + if header.lower().startswith("cookie:"): + header = header.split(":", 1)[1].strip().rstrip(";") if not header: return [] diff --git a/web_app.py b/web_app.py new file mode 100644 index 0000000..89f6ae6 --- /dev/null +++ b/web_app.py @@ -0,0 +1,486 @@ +#!/usr/bin/env python3 +"""Small web UI for exporting Sber operations to XLSX and JSON.""" + +from __future__ import annotations + +import argparse +import html +import json +import re +import zipfile +from datetime import datetime +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from tempfile import TemporaryDirectory +from urllib.parse import parse_qs +from uuid import uuid4 + +from load_data import SberMonthHistoryExporter +from netscape_cookies.parse_netscape_cookies import build_cookie_header, parse_cookies + + +MAX_FORM_BYTES = 2 * 1024 * 1024 +EXPORT_DIR = Path("data") / "exports" +PERIOD_RE = re.compile(r"^(\d{4})-(\d{2})(?:-\d{2})?$") + + +PAGE_HTML = """ + + + + + Sber History Export + + + +
+
+
+

Экспорт операций Сбера

+

Вставьте cookies в формате Netscape, JSON-массива или обычного Cookie header.

+
+
+ __MESSAGE__ +
+
    +
  1. Установите расширение Get cookies.txt LOCALLY.
  2. +
  3. Откройте СберБанк Онлайн и авторизуйтесь.
  4. +
  5. Скопируйте cookies через расширение и вставьте их в поле ниже.
  6. +
+
+ +
+ +
+
+ + В архиве будут Excel и JSON со списком всех операций. +
+
+
+
+ + + +""" + + +class ExportHandler(BaseHTTPRequestHandler): + server_version = "SberExportHTTP/1.0" + + def do_GET(self) -> None: + if self.path not in ("/", "/index.html"): + self.send_error(HTTPStatus.NOT_FOUND, "Not found") + return + self._send_page() + + def do_POST(self) -> None: + if self.path == "/validate-cookies": + self._handle_validate_cookies() + return + + if self.path != "/export": + self.send_error(HTTPStatus.NOT_FOUND, "Not found") + return + + try: + fields = self._read_form() + cookies_text = self._field(fields, "cookies").strip() + year, month = parse_period(self._field(fields, "period")) + + cookie_header, _ = parse_cookie_header_from_text(cookies_text) + archive_name, archive_bytes, count = export_archive(cookie_header, year, month) + except Exception as exc: + self._send_page(str(exc), status=HTTPStatus.BAD_REQUEST) + return + + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", "application/zip") + self.send_header("Content-Disposition", f'attachment; filename="{archive_name}"') + self.send_header("Content-Length", str(len(archive_bytes))) + self.send_header("X-Operations-Count", str(count)) + self.end_headers() + self.wfile.write(archive_bytes) + + def _handle_validate_cookies(self) -> None: + try: + fields = self._read_form() + _, cookie_count = parse_cookie_header_from_text(self._field(fields, "cookies").strip()) + payload = {"ok": True, "count": cookie_count} + status = HTTPStatus.OK + except Exception as exc: + payload = {"ok": False, "error": str(exc)} + status = HTTPStatus.BAD_REQUEST + + self._send_json(payload, status=status) + + def log_message(self, format: str, *args: object) -> None: + print(f"{self.address_string()} - {format % args}") + + def _read_form(self) -> dict[str, list[str]]: + content_length = int(self.headers.get("Content-Length", "0")) + if content_length > MAX_FORM_BYTES: + raise ValueError("Слишком большой ввод cookies.") + + raw_body = self.rfile.read(content_length).decode("utf-8") + return parse_qs(raw_body, keep_blank_values=True) + + def _field(self, fields: dict[str, list[str]], name: str, default: str = "") -> str: + values = fields.get(name) + return values[0] if values else default + + def _send_page(self, message: str = "", status: HTTPStatus = HTTPStatus.OK) -> None: + now = datetime.now() + notice = "" + if message: + notice = f'
{html.escape(message)}
' + body = ( + PAGE_HTML + .replace("__MESSAGE__", notice) + .replace("__PERIOD__", f"{now.year}-{now.month:02d}") + ) + + encoded = body.encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(encoded))) + self.end_headers() + self.wfile.write(encoded) + + def _send_json(self, payload: dict[str, object], status: HTTPStatus = HTTPStatus.OK) -> None: + encoded = json.dumps(payload, ensure_ascii=False).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Cache-Control", "no-store") + self.send_header("Content-Length", str(len(encoded))) + self.end_headers() + self.wfile.write(encoded) + + +def parse_cookie_header_from_text(cookies_text: str) -> tuple[str, int]: + if not cookies_text: + raise ValueError("Вставьте cookies.") + + cookies = parse_cookies(cookies_text, input_format="auto") + cookie_header = build_cookie_header(cookies) + if not cookie_header: + raise ValueError("Не удалось получить ни одной cookie из введенного текста.") + + return cookie_header, len(cookies) + + +def parse_period(value: str) -> tuple[int, int]: + match = PERIOD_RE.match(value.strip()) + if not match: + raise ValueError("Выберите период в формате ГГГГ-ММ.") + + year = int(match.group(1)) + month = int(match.group(2)) + if month < 1 or month > 12: + raise ValueError("Месяц должен быть от 1 до 12.") + return year, month + + +def export_archive(cookie_header: str, year: int, month: int) -> tuple[str, bytes, int]: + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") + base_name = f"sber_operations_{year}_{month:02d}_{timestamp}_{uuid4().hex[:8]}" + EXPORT_DIR.mkdir(parents=True, exist_ok=True) + + archive_path = EXPORT_DIR / f"{base_name}.zip" + + with TemporaryDirectory() as tmp_dir: + tmp_path = Path(tmp_dir) + xlsx_path = tmp_path / f"{base_name}.xlsx" + json_path = tmp_path / f"{base_name}.json" + + exporter = SberMonthHistoryExporter(cookie=cookie_header) + _, _, count = exporter.save_month_bundle(year, month, xlsx_path, json_path) + + with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED) as archive: + archive.write(xlsx_path, arcname=xlsx_path.name) + archive.write(json_path, arcname=json_path.name) + + return archive_path.name, archive_path.read_bytes(), count + + +def create_server(host: str, port: int) -> ThreadingHTTPServer: + return ThreadingHTTPServer((host, port), ExportHandler) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run web UI for Sber operations export.") + parser.add_argument("--host", default="127.0.0.1", help="Host to bind. Default: 127.0.0.1") + parser.add_argument("--port", type=int, default=8000, help="Port to bind. Default: 8000") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + server = create_server(args.host, args.port) + print(f"Open http://{args.host}:{args.port}") + try: + server.serve_forever() + except KeyboardInterrupt: + print("\nStopping server") + finally: + server.server_close() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())