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
+
+
+
+
+
+
+
+
+
+"""
+
+
+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())