1
0
Fork 0
mirror of https://github.com/EDeev/declaude.git synced 2026-10-07 20:49:57 +03:00
declaude/docs/fastapi-integration.md

536 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Claude Archive Converter — интеграция в FastAPI сайт
## Что нужно сделать
Встроить модуль конвертации Claude-бэкапа в статический HTML-архив как endpoint FastAPI-сайта.
**Пользовательский сценарий:**
1. Пользователь открывает страницу `/tools/claude-archive`
2. Перетаскивает или выбирает zip-файл (скачанный с claude.ai — экспорт данных)
3. Видит прогресс-бар обработки
4. Получает ссылку для скачивания готового zip-архива с HTML-страницами
5. Файл автоматически удаляется с сервера через 10 минут
---
## Ядро конвертера
Файл `declaude.py` уже существует и полностью работоспособен. Его **не нужно переписывать** — нужно только обернуть в веб-слой.
Ключевые классы из `declaude.py`:
- `DataLoader(source_dir)` — загружает `conversations.json`, `projects/*.json`, `users.json`
- `MappingManager(path)` — читает/пишет `mapping.json`
- `SiteBuilder(output_dir, loader, mapping)` — генерирует всё HTML
- `SiteBuilder.build_all()` — запускает полную генерацию
Пример минимального вызова (уже работает локально):
```python
from declaude import DataLoader, MappingManager, SiteBuilder
from pathlib import Path
loader = DataLoader(Path("extracted_backup/"))
loader.load()
mapping = MappingManager(Path("mapping.json"))
mapping.load()
site = SiteBuilder(Path("output/"), loader, mapping)
site.build_all()
```
---
## Структура файлов, которые нужно создать
```
your_project/
├── routers/
│ └── claude_archive.py # новый роутер FastAPI
├── services/
│ └── claude_converter.py # бизнес-логика: распаковка, запуск, упаковка
├── templates/
│ └── claude_archive.html # страница загрузки (Jinja2 или отдельный HTML)
├── static/
│ └── claude_archive.js # фронтенд: upload, polling, progress bar
└── declaude.py # уже существует — НЕ ТРОГАТЬ
```
Подключи роутер в `main.py`:
```python
from routers.claude_archive import router as claude_archive_router
app.include_router(claude_archive_router, prefix="/tools")
```
---
## routers/claude_archive.py
```python
import asyncio
import uuid
import zipfile
import shutil
from pathlib import Path
from typing import Dict
from fastapi import APIRouter, UploadFile, File, HTTPException, BackgroundTasks
from fastapi.responses import FileResponse, JSONResponse
router = APIRouter(tags=["claude-archive"])
# Временная директория для задач
WORK_DIR = Path("/tmp/claude_archive_jobs")
WORK_DIR.mkdir(parents=True, exist_ok=True)
# Хранилище состояний задач в памяти (для prod замени на Redis)
jobs: Dict[str, dict] = {}
@router.get("/claude-archive")
async def page():
"""Страница загрузки — отдаёт HTML."""
from fastapi.responses import HTMLResponse
# Если используешь Jinja2, подключи TemplateResponse
html = Path("templates/claude_archive.html").read_text(encoding="utf-8")
return HTMLResponse(html)
@router.post("/claude-archive/upload")
async def upload(background_tasks: BackgroundTasks, file: UploadFile = File(...)):
"""
Принимает zip-файл, запускает конвертацию в фоне.
Возвращает job_id для polling статуса.
"""
if not file.filename.endswith(".zip"):
raise HTTPException(400, "Ожидается .zip файл")
# Ограничение размера: 500 MB
MAX_SIZE = 500 * 1024 * 1024
content = await file.read()
if len(content) > MAX_SIZE:
raise HTTPException(413, "Файл слишком большой (макс. 500 MB)")
job_id = str(uuid.uuid4())
job_dir = WORK_DIR / job_id
job_dir.mkdir()
# Сохраняем загруженный zip
upload_path = job_dir / "upload.zip"
upload_path.write_bytes(content)
# Регистрируем задачу
jobs[job_id] = {"status": "queued", "progress": 0, "error": None}
# Запускаем конвертацию в фоне
background_tasks.add_task(run_conversion, job_id, job_dir)
return JSONResponse({"job_id": job_id})
@router.get("/claude-archive/status/{job_id}")
async def status(job_id: str):
"""Polling endpoint — возвращает статус задачи."""
if job_id not in jobs:
raise HTTPException(404, "Задача не найдена")
return JSONResponse(jobs[job_id])
@router.get("/claude-archive/download/{job_id}")
async def download(job_id: str):
"""Скачать готовый архив."""
if job_id not in jobs:
raise HTTPException(404, "Задача не найдена")
if jobs[job_id]["status"] != "done":
raise HTTPException(400, "Архив ещё не готов")
result_zip = WORK_DIR / job_id / "result.zip"
if not result_zip.exists():
raise HTTPException(404, "Файл не найден")
return FileResponse(
path=str(result_zip),
media_type="application/zip",
filename="claude_archive.zip",
headers={"Content-Disposition": "attachment; filename=claude_archive.zip"}
)
```
---
## services/claude_converter.py
```python
import asyncio
import zipfile
import shutil
import traceback
from pathlib import Path
from routers.claude_archive import jobs, WORK_DIR
async def run_conversion(job_id: str, job_dir: Path):
"""
Фоновая задача: распаковать upload.zip, запустить declaude, упаковать результат.
Обновляет jobs[job_id] по ходу выполнения.
Удаляет все файлы через 10 минут после завершения.
"""
try:
jobs[job_id]["status"] = "extracting"
jobs[job_id]["progress"] = 5
# 1. Распаковываем загруженный архив
extract_dir = job_dir / "extracted"
extract_dir.mkdir()
with zipfile.ZipFile(job_dir / "upload.zip", "r") as zf:
zf.extractall(extract_dir)
# Если внутри zip есть одна папка — используем её как source
children = list(extract_dir.iterdir())
source_dir = children[0] if len(children) == 1 and children[0].is_dir() else extract_dir
jobs[job_id]["status"] = "loading"
jobs[job_id]["progress"] = 15
# 2. Запускаем тяжёлую работу в thread pool (не блокируем event loop)
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, _build_archive, job_id, source_dir, job_dir)
except Exception as e:
jobs[job_id]["status"] = "error"
jobs[job_id]["error"] = str(e)
traceback.print_exc()
finally:
# Удаляем файлы через 10 минут
await asyncio.sleep(600)
shutil.rmtree(job_dir, ignore_errors=True)
jobs.pop(job_id, None)
def _build_archive(job_id: str, source_dir: Path, job_dir: Path):
"""
Синхронная часть — выполняется в thread pool.
Импортируем declaude здесь чтобы не тащить в event loop.
"""
import sys
import os
# declaude.py лежит в корне проекта — добавляем в path если нужно
sys.path.insert(0, str(Path(__file__).parent.parent))
from declaude import DataLoader, MappingManager, SiteBuilder
output_dir = job_dir / "output"
output_dir.mkdir()
jobs[job_id]["status"] = "building"
jobs[job_id]["progress"] = 30
# Загружаем данные
loader = DataLoader(source_dir)
loader.load()
# Пустой маппинг (пользователь может настроить потом через браузер)
mapping = MappingManager(job_dir / "mapping.json")
mapping.load()
jobs[job_id]["progress"] = 50
# Генерируем HTML-архив
site = SiteBuilder(output_dir, loader, mapping)
site.build_all()
jobs[job_id]["status"] = "packing"
jobs[job_id]["progress"] = 85
# Упаковываем результат
result_zip = job_dir / "result.zip"
_zip_directory(output_dir, result_zip)
jobs[job_id]["status"] = "done"
jobs[job_id]["progress"] = 100
def _zip_directory(source_dir: Path, output_path: Path):
"""Рекурсивно запаковывает директорию в zip с относительными путями."""
with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf:
for file_path in source_dir.rglob("*"):
if file_path.is_file():
arcname = file_path.relative_to(source_dir)
zf.write(file_path, arcname)
```
---
## templates/claude_archive.html
Минимальная страница с drag-and-drop загрузкой и прогресс-баром. Адаптируй под свою систему шаблонов (Jinja2, etc.) и дизайн сайта.
```html
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Claude Archive Converter</title>
<style>
/* Подключи свои глобальные стили, это только специфика виджета */
.converter-box {
max-width: 560px;
margin: 3rem auto;
padding: 2rem;
border: 1px solid var(--border, #383838);
border-radius: 12px;
background: var(--bg2, #242424);
font-family: inherit;
}
.drop-zone {
border: 2px dashed var(--border, #444);
border-radius: 8px;
padding: 3rem 1rem;
text-align: center;
cursor: pointer;
transition: border-color 0.15s;
}
.drop-zone.drag-over { border-color: #d97706; background: rgba(217,119,6,0.05); }
.drop-zone input[type=file] { display: none; }
.progress-bar-wrap {
background: var(--bg3, #2e2e2e);
border-radius: 6px;
height: 8px;
overflow: hidden;
margin: 1rem 0;
}
.progress-bar-fill {
height: 100%;
background: #d97706;
border-radius: 6px;
transition: width 0.3s;
width: 0%;
}
.status-msg { font-size: 0.9rem; color: #a0a0a0; margin: 0.5rem 0; }
.btn-download {
display: inline-block;
padding: 0.6rem 1.4rem;
background: #d97706;
color: #fff;
border-radius: 8px;
text-decoration: none;
font-weight: 600;
margin-top: 1rem;
}
.error-msg { color: #f87171; font-size: 0.9rem; margin-top: 0.5rem; }
</style>
</head>
<body>
<div class="converter-box">
<h2>Claude Archive Converter</h2>
<p style="color:#a0a0a0; font-size:0.9rem; margin-bottom:1.5rem;">
Загрузите zip-файл экспорта данных Claude (Settings → Privacy → Export data).
Вы получите красивый HTML-архив всех ваших чатов и проектов.
</p>
<div class="drop-zone" id="dropZone">
<input type="file" id="fileInput" accept=".zip">
<div id="dropLabel">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="#666" stroke-width="1.5" style="margin-bottom:0.75rem">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>
</svg>
<div>Перетащите .zip сюда или <strong style="color:#d97706">выберите файл</strong></div>
<div style="font-size:0.8rem; color:#666; margin-top:0.4rem">Максимум 500 MB</div>
</div>
</div>
<div id="progressSection" style="display:none">
<div class="progress-bar-wrap"><div class="progress-bar-fill" id="progressFill"></div></div>
<div class="status-msg" id="statusMsg">Загрузка...</div>
</div>
<div id="errorSection" style="display:none">
<div class="error-msg" id="errorMsg"></div>
<button onclick="resetForm()" style="margin-top:0.5rem; cursor:pointer">Попробовать ещё раз</button>
</div>
<div id="doneSection" style="display:none">
<div style="color:#4ade80; margin-bottom:0.5rem">✓ Готово! Архив сформирован.</div>
<a class="btn-download" id="downloadLink" href="#">Скачать claude_archive.zip</a>
<div style="font-size:0.75rem; color:#666; margin-top:0.5rem">Файл будет удалён с сервера через 10 минут</div>
</div>
</div>
<script src="/static/claude_archive.js"></script>
</body>
</html>
```
---
## static/claude_archive.js
```javascript
const BASE = "/tools/claude-archive";
// Drag & drop
const dropZone = document.getElementById("dropZone");
const fileInput = document.getElementById("fileInput");
dropZone.addEventListener("click", () => fileInput.click());
dropZone.addEventListener("dragover", (e) => { e.preventDefault(); dropZone.classList.add("drag-over"); });
dropZone.addEventListener("dragleave", () => dropZone.classList.remove("drag-over"));
dropZone.addEventListener("drop", (e) => {
e.preventDefault();
dropZone.classList.remove("drag-over");
const file = e.dataTransfer.files[0];
if (file) handleFile(file);
});
fileInput.addEventListener("change", () => {
if (fileInput.files[0]) handleFile(fileInput.files[0]);
});
function handleFile(file) {
if (!file.name.endsWith(".zip")) {
showError("Ожидается файл .zip");
return;
}
uploadFile(file);
}
function uploadFile(file) {
showProgress(5, "Загрузка файла на сервер...");
const formData = new FormData();
formData.append("file", file);
fetch(`${BASE}/upload`, { method: "POST", body: formData })
.then((r) => {
if (!r.ok) return r.json().then((d) => { throw new Error(d.detail || "Ошибка загрузки"); });
return r.json();
})
.then((data) => pollStatus(data.job_id))
.catch((err) => showError(err.message));
}
let pollTimer = null;
function pollStatus(jobId) {
const STATUS_LABELS = {
queued: "В очереди...",
extracting: "Распаковка архива...",
loading: "Загрузка данных...",
building: "Генерация HTML-страниц...",
packing: "Упаковка результата...",
done: "Готово!",
error: "Ошибка",
};
fetch(`${BASE}/status/${jobId}`)
.then((r) => r.json())
.then((data) => {
const label = STATUS_LABELS[data.status] || data.status;
showProgress(data.progress || 0, label);
if (data.status === "done") {
showDone(jobId);
} else if (data.status === "error") {
showError(data.error || "Неизвестная ошибка");
} else {
// Продолжаем polling каждые 1.5 секунды
pollTimer = setTimeout(() => pollStatus(jobId), 1500);
}
})
.catch((err) => showError("Ошибка polling: " + err.message));
}
// UI helpers
function showProgress(pct, msg) {
document.getElementById("dropZone").style.display = "none";
document.getElementById("progressSection").style.display = "block";
document.getElementById("errorSection").style.display = "none";
document.getElementById("doneSection").style.display = "none";
document.getElementById("progressFill").style.width = pct + "%";
document.getElementById("statusMsg").textContent = msg;
}
function showDone(jobId) {
document.getElementById("progressSection").style.display = "none";
document.getElementById("doneSection").style.display = "block";
document.getElementById("downloadLink").href = `${BASE}/download/${jobId}`;
}
function showError(msg) {
clearTimeout(pollTimer);
document.getElementById("progressSection").style.display = "none";
document.getElementById("dropZone").style.display = "block";
document.getElementById("errorSection").style.display = "block";
document.getElementById("errorMsg").textContent = msg;
}
function resetForm() {
document.getElementById("errorSection").style.display = "none";
document.getElementById("doneSection").style.display = "none";
document.getElementById("dropZone").style.display = "block";
document.getElementById("progressFill").style.width = "0%";
}
```
---
## Зависимости
Ничего нового — всё уже есть в FastAPI. Только убедись что в `requirements.txt` есть:
```
fastapi
uvicorn
python-multipart # нужен для UploadFile
```
`python-multipart` нужен FastAPI для обработки `multipart/form-data` (загрузка файлов). Без него `UploadFile` не работает.
---
## Важные решения и ограничения
### Хранилище состояний задач
В коде выше `jobs` — это обычный Python dict в памяти. Это работает для **одного воркера**. Если у тебя несколько воркеров uvicorn (`--workers N`), состояние будет разделено между процессами и polling сломается.
**Если используешь несколько воркеров:** замени `jobs` dict на Redis:
```python
import redis, json
r = redis.Redis()
# запись: r.setex(f"job:{job_id}", 700, json.dumps(state))
# чтение: json.loads(r.get(f"job:{job_id}"))
```
### Очистка файлов
Временные файлы удаляются через `asyncio.sleep(600)` после завершения задачи. Если сервер перезапустится в процессе — файлы останутся. Добавь startup-хук для очистки старых папок:
```python
@app.on_event("startup")
async def cleanup_old_jobs():
shutil.rmtree(WORK_DIR, ignore_errors=True)
WORK_DIR.mkdir(parents=True)
```
### Размер архива и таймауты
Conversations.json может весить 300+ MB. Обработка 950 диалогов занимает ~70 секунд. Убедись что у nginx/proxy нет таймаута на загрузку файла. Рекомендуемые настройки nginx:
```nginx
client_max_body_size 600M;
proxy_read_timeout 600s;
proxy_send_timeout 600s;
```
### Параллельные задачи
`run_in_executor` использует `ThreadPoolExecutor` по умолчанию (количество потоков = CPU * 5). При высокой нагрузке создай явный executor с ограничением:
```python
from concurrent.futures import ThreadPoolExecutor
executor = ThreadPoolExecutor(max_workers=3)
await loop.run_in_executor(executor, _build_archive, ...)
```
---
## Порядок реализации для Claude Code
1. Скопировать `declaude.py` в корень проекта (или настроить `sys.path` в `claude_converter.py`)
2. Создать `routers/claude_archive.py` по шаблону выше
3. Создать `services/claude_converter.py`, импортировать `run_conversion` в роутер
4. Создать `templates/claude_archive.html` и `static/claude_archive.js`
5. Добавить `app.include_router(...)` в `main.py`
6. Добавить `python-multipart` в зависимости
7. Проверить что nginx разрешает загрузку файлов до 500 MB