1
0
Fork 0
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:
Egor Deev 2026-09-29 14:40:15 +03:00
commit b2d9deb4b4
6 changed files with 2751 additions and 0 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
__pycache__/
*.pyc
claude-backup-v1/
claude_archive/

536
INTEGRATION.md Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

1
mapping.json Normal file
View file

@ -0,0 +1 @@
{}