Add web export bundle and cookie header parsing

This commit is contained in:
Alex 2026-06-15 16:58:29 +03:00
parent f655edf578
commit 9fc382ae28
10 changed files with 648 additions and 1 deletions

7
.dockerignore Normal file
View File

@ -0,0 +1,7 @@
.git
.idea
.venv
__pycache__/
*.py[cod]
.DS_Store
data/

5
.gitignore vendored
View File

@ -1,2 +1,5 @@
.idea
data
data
__pycache__/
*.py[cod]
.DS_Store

View File

@ -0,0 +1,11 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="docker-compose.yml: Compose Deployment" type="docker-deploy" factoryName="docker-compose.yml" server-name="3. 1 GB">
<deployment type="docker-compose.yml">
<settings>
<option name="commandLineOptions" value="--build" />
<option name="sourceFilePath" value="docker-compose.yml" />
</settings>
</deployment>
<method v="2" />
</configuration>
</component>

15
Dockerfile Normal file
View File

@ -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"]

View File

@ -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

26
docker-compose.yml Normal file
View File

@ -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:

View File

@ -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)]

View File

@ -50,6 +50,12 @@
session_id=abc123; theme=dark;
```
Также можно вставить заголовок целиком с префиксом:
```text
Cookie: session_id=abc123; theme=dark;
```
У этого формата нет метаданных вроде `domain`, `expires`, `secure` и `httpOnly`, поэтому скрипт заполняет их пустыми или безопасными значениями по умолчанию.
## Использование

View File

@ -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 []

486
web_app.py Normal file
View File

@ -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 = """<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sber History Export</title>
<style>
:root {
color-scheme: light;
--bg: #f5f7fa;
--panel: #ffffff;
--text: #17202a;
--muted: #5f6b7a;
--line: #d9e0e8;
--accent: #12805c;
--accent-dark: #0b6248;
--danger: #a33131;
--danger-bg: #fff1f1;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--text);
font: 16px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
main {
width: min(920px, calc(100% - 32px));
margin: 0 auto;
padding: 40px 0;
}
.shell {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: 0 12px 28px rgba(31, 45, 61, 0.08);
overflow: hidden;
}
header {
padding: 28px 32px 22px;
border-bottom: 1px solid var(--line);
}
h1 {
margin: 0 0 8px;
font-size: 28px;
line-height: 1.15;
letter-spacing: 0;
}
p {
margin: 0;
color: var(--muted);
}
form {
padding: 28px 32px 32px;
display: grid;
gap: 22px;
}
label {
display: grid;
gap: 8px;
font-weight: 650;
}
.steps {
margin: 0;
padding-left: 22px;
color: var(--text);
}
.steps li {
margin: 6px 0;
}
.steps a {
color: var(--accent-dark);
font-weight: 650;
}
textarea,
input,
select {
width: 100%;
border: 1px solid var(--line);
border-radius: 6px;
color: var(--text);
background: #fff;
font: inherit;
}
textarea {
min-height: 220px;
resize: vertical;
padding: 14px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 14px;
line-height: 1.45;
}
input,
select {
min-height: 44px;
padding: 0 12px;
}
.grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(180px, 240px);
gap: 18px;
align-items: end;
}
.grid.single {
grid-template-columns: minmax(180px, 320px);
}
.hint {
color: var(--muted);
font-size: 14px;
font-weight: 400;
}
.hint a {
color: var(--accent-dark);
font-weight: 650;
}
.cookie-status {
min-height: 22px;
font-size: 14px;
font-weight: 500;
}
.cookie-status.ok {
color: var(--accent-dark);
}
.cookie-status.error {
color: var(--danger);
}
.actions {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
button {
min-height: 46px;
border: 0;
border-radius: 6px;
padding: 0 18px;
background: var(--accent);
color: #fff;
font: inherit;
font-weight: 700;
cursor: pointer;
}
button:hover {
background: var(--accent-dark);
}
.notice {
border: 1px solid var(--line);
border-radius: 6px;
padding: 12px 14px;
background: #f9fbfc;
color: var(--muted);
font-size: 14px;
}
.error {
border-color: #e7b5b5;
background: var(--danger-bg);
color: var(--danger);
}
@media (max-width: 680px) {
main {
width: min(100% - 20px, 920px);
padding: 18px 0;
}
header,
form {
padding-left: 18px;
padding-right: 18px;
}
.grid {
grid-template-columns: 1fr;
}
h1 {
font-size: 24px;
}
}
</style>
</head>
<body>
<main>
<section class="shell">
<header>
<h1>Экспорт операций Сбера</h1>
<p>Вставьте cookies в формате Netscape, JSON-массива или обычного Cookie header.</p>
</header>
<form method="post" action="/export">
__MESSAGE__
<div>
<ol class="steps">
<li>Установите расширение <a href="https://chromewebstore.google.com/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc?pli=1" target="_blank" rel="noopener noreferrer">Get cookies.txt LOCALLY</a>.</li>
<li>Откройте <a href="https://online.sberbank.ru/CSAFront/index.do" target="_blank" rel="noopener noreferrer">СберБанк Онлайн</a> и авторизуйтесь.</li>
<li>Скопируйте cookies через расширение и вставьте их в поле ниже.</li>
</ol>
</div>
<label>
Cookies
<textarea name="cookies" spellcheck="false" autocomplete="off" required placeholder="session_id=...; other_cookie=..."></textarea>
<span class="cookie-status" id="cookie-status" role="status" aria-live="polite"></span>
<span class="hint">
Cookies используются только для запроса к Сберу и не сохраняются.
Получить `cookies.txt` можно через
<a href="https://chromewebstore.google.com/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc?pli=1" target="_blank" rel="noopener noreferrer">Get cookies.txt LOCALLY</a>.
</span>
</label>
<div class="grid single">
<label>
Период
<input type="month" name="period" value="__PERIOD__" required>
</label>
</div>
<div class="actions">
<button type="submit">Экспортировать ZIP</button>
<span class="hint">В архиве будут Excel и JSON со списком всех операций.</span>
</div>
</form>
</section>
</main>
<script>
const cookiesInput = document.querySelector('textarea[name="cookies"]');
const statusNode = document.querySelector('#cookie-status');
let validateTimer = null;
function setCookieStatus(text, mode) {
statusNode.textContent = text;
statusNode.className = mode ? `cookie-status ${mode}` : 'cookie-status';
}
async function validateCookies() {
const cookies = cookiesInput.value.trim();
if (!cookies) {
setCookieStatus('', '');
return;
}
try {
const response = await fetch('/validate-cookies', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' },
body: new URLSearchParams({ cookies }),
});
const result = await response.json();
if (result.ok) {
setCookieStatus(`Формат распознан, cookies: ${result.count}`, 'ok');
} else {
setCookieStatus(result.error || 'Cookies не удалось распарсить.', 'error');
}
} catch {
setCookieStatus('Не удалось проверить cookies.', 'error');
}
}
cookiesInput.addEventListener('input', () => {
clearTimeout(validateTimer);
validateTimer = setTimeout(validateCookies, 250);
});
</script>
</body>
</html>
"""
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'<div class="notice error">{html.escape(message)}</div>'
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())