Add web export bundle and cookie header parsing
This commit is contained in:
parent
f655edf578
commit
9fc382ae28
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@ -0,0 +1,7 @@
|
||||
.git
|
||||
.idea
|
||||
.venv
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.DS_Store
|
||||
data/
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@ -1,2 +1,5 @@
|
||||
.idea
|
||||
data
|
||||
data
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.DS_Store
|
||||
|
||||
11
.run/docker-compose.yml_ Compose Deployment.run.xml
Normal file
11
.run/docker-compose.yml_ Compose Deployment.run.xml
Normal 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
15
Dockerfile
Normal 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"]
|
||||
64
README.md
64
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
|
||||
|
||||
26
docker-compose.yml
Normal file
26
docker-compose.yml
Normal 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:
|
||||
27
load_data.py
27
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)]
|
||||
|
||||
@ -50,6 +50,12 @@
|
||||
session_id=abc123; theme=dark;
|
||||
```
|
||||
|
||||
Также можно вставить заголовок целиком с префиксом:
|
||||
|
||||
```text
|
||||
Cookie: session_id=abc123; theme=dark;
|
||||
```
|
||||
|
||||
У этого формата нет метаданных вроде `domain`, `expires`, `secure` и `httpOnly`, поэтому скрипт заполняет их пустыми или безопасными значениями по умолчанию.
|
||||
|
||||
## Использование
|
||||
|
||||
@ -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
486
web_app.py
Normal 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())
|
||||
Loading…
x
Reference in New Issue
Block a user