mirror of
https://github.com/EDeev/declaude.git
synced 2026-10-07 20:49:57 +03:00
declaude: конвертер экспорта Claude.ai в статический HTML-архив
This commit is contained in:
commit
b2d9deb4b4
6 changed files with 2751 additions and 0 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
claude-backup-v1/
|
||||||
|
claude_archive/
|
||||||
536
INTEGRATION.md
Normal file
536
INTEGRATION.md
Normal file
|
|
@ -0,0 +1,536 @@
|
||||||
|
# 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
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Egor Deev
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
91
README.md
Normal file
91
README.md
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
# declaude
|
||||||
|
|
||||||
|
Автономная утилита для преобразования официального экспорта данных Claude.ai в интерактивный статический HTML-архив.
|
||||||
|
|
||||||
|
Позволяет локально хранить, просматривать и быстро искать диалоги без внешних зависимостей и необходимости поднимать базы данных или веб-серверы.
|
||||||
|
|
||||||
|
## Онлайн-версия
|
||||||
|
|
||||||
|
Не хочется устанавливать Python и разбираться руками — есть готовая веб-версия:
|
||||||
|
**https://fe0.ru/declaude/**. Загружаете zip-экспорт, получаете готовый архив — без установки
|
||||||
|
чего-либо. Если важна приватность данных, запустите скрипт локально по инструкции ниже.
|
||||||
|
|
||||||
|
## Возможности
|
||||||
|
|
||||||
|
- **Полная автономность:** генерирует чистые HTML-страницы для каждого диалога, которые открываются в любом браузере.
|
||||||
|
- **Страницы проектов:** рендеринг контекстных документов и шаблонов системных промптов для каждого проекта.
|
||||||
|
- **Полноценная поддержка Markdown:** заголовки, списки, таблицы, цитаты, блоки кода.
|
||||||
|
- **Подсветка синтаксиса без сторонних библиотек:** встроенная поддержка Python, JavaScript, TypeScript, SQL, Bash, Go и Rust.
|
||||||
|
- **Темы оформления и языки:** переключение между светлой и тёмной темой, а также интерфейс на русском и английском языках (выбор сохраняется в LocalStorage).
|
||||||
|
- **Маппинг проектов:** возможность связывать диалоги с проектами прямо в браузере с выгрузкой файла `mapping.json` и быстрой пересборкой.
|
||||||
|
- **Инкрементальные обновления:** режим `--update` обновляет только добавленные или изменённые диалоги.
|
||||||
|
- **Нулевые зависимости:** работает исключительно на стандартной библиотеке Python 3.8+.
|
||||||
|
|
||||||
|
## Подготовка данных
|
||||||
|
|
||||||
|
1. Откройте **claude.ai → Settings → Privacy → Export data**.
|
||||||
|
2. Дождитесь письма со ссылкой и скачайте zip-архив с выгрузкой.
|
||||||
|
3. Распакуйте архив в отдельную папку. Внутри находятся файлы `conversations.json`, `users.json` и каталог `projects/`.
|
||||||
|
|
||||||
|
## Использование
|
||||||
|
|
||||||
|
### Первая сборка архива
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python declaude.py --build --source ./claude-export
|
||||||
|
```
|
||||||
|
|
||||||
|
После завершения откройте файл `claude_archive/index.html` в браузере.
|
||||||
|
|
||||||
|
### Указание своей папки для архива
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python declaude.py --build --source ./claude-export --output ./my-archive
|
||||||
|
```
|
||||||
|
|
||||||
|
### Инкрементальное обновление из нового архива
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python declaude.py --update new_export.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
Регенерируются только новые и отредактированные страницы, текущая привязка диалогов к проектам в `mapping.json` сохраняется.
|
||||||
|
|
||||||
|
### Назначение диалога проекту через консоль
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python declaude.py --map <conversation_uuid> <project_uuid>
|
||||||
|
python declaude.py --remap
|
||||||
|
```
|
||||||
|
|
||||||
|
Быстрая пересборка страниц проектов (`--remap`) занимает секунды, так как не пересобирает неизменённые диалоги.
|
||||||
|
|
||||||
|
## Структура сгенерированного архива
|
||||||
|
|
||||||
|
```
|
||||||
|
claude_archive/
|
||||||
|
├── index.html # Главная страница: статистика, проекты, последние чаты
|
||||||
|
├── all_conversations.html # Полный список диалогов с поиском и фильтрацией
|
||||||
|
├── conversations/
|
||||||
|
│ └── <uuid>.html # Страницы отдельных диалогов
|
||||||
|
├── projects/
|
||||||
|
│ └── <uuid>/
|
||||||
|
│ └── index.html # Страницы проектов с документами и диалогами
|
||||||
|
└── assets/
|
||||||
|
├── style.css
|
||||||
|
└── app.js
|
||||||
|
```
|
||||||
|
|
||||||
|
## Параметры командной строки
|
||||||
|
|
||||||
|
- `--build` — полная сборка архива из исходного каталога.
|
||||||
|
- `--source ПУТЬ` — путь к каталогу с распакованным экспортом (по умолчанию: `claude-backup-v1`).
|
||||||
|
- `--output ПУТЬ` — каталог для сохранения HTML-архива (по умолчанию: `claude_archive`).
|
||||||
|
- `--update АРХИВ.ZIP` — добавление данных из нового zip-архива с сохранением маппинга.
|
||||||
|
- `--remap` — быстрая пересборка индексной страницы и страниц проектов.
|
||||||
|
- `--map CONV_ID PROJ_ID` — ручное сопоставление диалога с проектом.
|
||||||
|
- `--mapping ПУТЬ` — путь к файлу сопоставлений (по умолчанию: `./mapping.json`).
|
||||||
|
|
||||||
|
## Лицензия
|
||||||
|
|
||||||
|
MIT
|
||||||
2098
declaude.py
Normal file
2098
declaude.py
Normal file
File diff suppressed because it is too large
Load diff
1
mapping.json
Normal file
1
mapping.json
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{}
|
||||||
Loading…
Add table
Reference in a new issue