Files
2026-04-18 11:29:36 +03:00

227 lines
19 KiB
Markdown
Raw Permalink 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.
# План: md-to-html — Streamlit UI + публичный API в Docker
## Context
Сейчас в проекте есть CLI-скрипт `md_to_html.py`, который через GitHub Markdown API конвертирует `.md` файл в самодостаточный HTML (с CSS-шаблоном `md/template.html`). Нужно превратить его в сервис с двумя интерфейсами:
1. **Streamlit UI** — загрузить `.md`, нажать кнопку, увидеть превью HTML и скачать результат.
2. **Публичный REST API** — принимает markdown-текст, отдаёт готовый HTML. Для интеграции с другими приложениями.
Всё это должно упаковываться в Docker-образ.
Решения, зафиксированные по ходу обсуждения:
- FastAPI и Streamlit живут в **одном контейнере** (два процесса, запуск через стартовый скрипт).
- Возвращается **полная HTML-страница** с применённым `template.html` — и в API, и в превью Streamlit.
- Читаем `GITHUB_TOKEN` из env (опционально, для обхода лимита 60/час).
- API **без аутентификации и rate-limiting** — минимальный стартовый вариант.
## Архитектура
```
md-to-html/
├── md_to_html.py # оставить как есть (работающий CLI)
├── md/template.html # без изменений, используется как раньше
├── app/
│ ├── __init__.py
│ ├── converter.py # общая логика (вынесена из md_to_html.py)
│ ├── api.py # FastAPI приложение
│ └── streamlit_app.py # Streamlit UI
├── requirements.txt
├── Dockerfile
├── start.py # Python-супервизор: запускает uvicorn + streamlit
├── .dockerignore
└── README.md # короткая инструкция запуска
```
Порты в контейнере: FastAPI — 8000, Streamlit — 8501. Оба пробрасываются наружу.
## Что делать
### 1. `app/converter.py` — общий модуль
Вынести из `md_to_html.py` переиспользуемые функции (без изменения логики):
- `render_markdown(markdown_text: str) -> str` — вызов GitHub API. Добавить чтение `GITHUB_TOKEN` из env: если задан, слать `Authorization: Bearer <token>`.
- `FirstHeadingParser` + `extract_title(html_text, fallback) -> str`.
- `apply_template(template_text, html_text, title) -> str`.
- Хелпер `convert(markdown_text: str, fallback_title: str = "Document") -> str` — объединяет три шага и читает `md/template.html` один раз (кэшировать через `functools.lru_cache`).
Путь к шаблону: `Path(__file__).resolve().parent.parent / "md" / "template.html"`.
`md_to_html.py` переписать так, чтобы он импортировал `convert` из `app.converter` — убрать дублирование. CLI-поведение сохранить (входной путь → записать рядом `.html`).
### 2. `app/api.py` — FastAPI
Endpoints:
- `POST /convert`
- Тело: `{"markdown": "<text>", "title": "<optional>"}` — Pydantic-модель с `field_validator` на `markdown`, проверяющим `len(value.encode("utf-8")) <= MAX_MARKDOWN_BYTES` (именно **байты UTF-8**, не `constr(max_length=...)` — тот считает символы). При превышении — `raise HTTPException(status_code=413, detail=...)`, чтобы обойти дефолтный `422` FastAPI-валидатора.
- **Приоритет title (зафиксировано явно):**
1. Первый `<h1..h6>` из отрендеренного HTML.
2. Если heading отсутствует — переданный `title` из запроса.
3. Если и его нет — `"Document"`.
- Ответ: `text/html` с полной страницей (`Response(content=..., media_type="text/html; charset=utf-8")`).
- Ошибки: пустой markdown → `400` (через отдельную проверку в роуте, до валидации); превышение размера → `413` (через ручной `HTTPException` в валидаторе, см. выше); `RuntimeError` от GitHub API → `502 Bad Gateway` с текстом исключения.
- Дополнительно: `exception_handler(RequestValidationError)` перехватывает Pydantic-422 и возвращает структурированный `400` — чтобы публичный API не отдавал разные коды на разные виды плохого ввода.
- `GET /health``{"status": "ok"}` для проверки.
- `GET /ready` → проверяет, что шаблон загружен и при желании пингует `https://api.github.com` (опционально).
**Лимит размера запроса (два уровня, defence-in-depth):**
1. **Hard guard — ASGI middleware до парсинга тела.** Читает `Content-Length` и, если превышает `MAX_REQUEST_BYTES = 1_200_000` (немного больше лимита на поле, чтобы учитывать JSON-обёртку), возвращает `413` без чтения тела. Если `Content-Length` отсутствует (chunked) — аккуратно считать байты из `receive()` и обрывать при превышении. Это настоящий request-size guard, не post-parse.
2. **Soft guard — Pydantic `field_validator` на поле `markdown`,** проверяющий `len(value.encode("utf-8")) <= MAX_MARKDOWN_BYTES` (`1_048_576`, 1 МБ) и поднимающий `HTTPException(413)`. Это вторая линия — на случай, если middleware обойдут (прокси, переписанные заголовки).
GitHub Markdown API сам ограничивает вход ~400 КБ, поэтому 1 МБ на стороне сервиса — безопасный запас с отсечкой абьюза. Значения вынести в env `MAX_MARKDOWN_BYTES` и `MAX_REQUEST_BYTES`.
CORS открыть для всех origin (`allow_origins=["*"]`, `allow_methods=["POST","GET"]`, `allow_headers=["content-type"]`).
### 3. `app/streamlit_app.py` — UI
Минимальный интерфейс:
1. `st.title("Markdown → HTML")`
2. `st.file_uploader("Загрузите .md файл", type=["md", "markdown"])`
3. Кнопка `st.button("Конвертировать")` — активна только когда файл загружен.
4. После клика: вызвать `convert()` из `app.converter` напрямую (не через HTTP — это тот же Python-импорт, один процесс).
5. Результат (три способа посмотреть, без deprecated API и без iframe-споров):
- **Inline-превью (approximate):** извлечь содержимое `<body>` из результата (простой regex/BeautifulSoup — фрагмент HTML без `<html>/<head>`, без CSS из template) и отрендерить через `st.markdown(body_html, unsafe_allow_html=True)`. Это приблизительный рендер без стилей template — нужен для быстрой проверки разметки. Явно подписать: *«Inline-превью без стилей. Для точного вида — «Открыть превью в новой вкладке» или скачайте файл.»*
- **Превью в новой вкладке:** `st.link_button("Открыть превью", url=f"data:text/html;charset=utf-8;base64,{b64(html_result)}")` — data-URL с полной страницей. Визуально идентично скачанному файлу, без component-API. **Fallback на большие документы:** если `len(html_result.encode()) > 1_500_000`, кнопка не рендерится, вместо неё показать `st.info("Документ слишком большой для превью в браузере. Скачайте файл.")` — браузеры ограничивают длину data-URL (~2 МБ в Chrome), а base64-кодирование раздувает payload в 1.33×.
- **Скачивание:** `st.download_button("Скачать HTML", data=html_result, file_name=f"{stem}.html", mime="text/html")`.
- **Сырой HTML:** `st.expander("Показать исходный HTML")` с `st.code(html_result, language="html")`.
Обоснование отказа от `st.components.v1.html` / `st.components.v2.*`: project skill `developing-with-streamlit` помечает v1 как deprecated, а v2 — это API для custom components с Python↔JS (`st.components.v2.component()`), не для простого показа HTML. Для нашего случая native-решения (`st.markdown` + `st.link_button` с data-URL) достаточно.
6. Хранить результат в `st.session_state["html_result"]`, чтобы повторный rerun (клик по expander, download) не терял его и не гонял GitHub API заново.
Обработка ошибок: `try/except RuntimeError``st.error(str(e))`.
### 4. Зависимости и окружение (локальная разработка)
Локально использовать **`uv`** + виртуальное окружение, не системный `pip`:
```bash
uv venv .venv # создать venv в .venv/
source .venv/bin/activate # (или `uv run <cmd>` без активации)
uv pip install -r requirements.txt
```
Либо эквивалент через `uv pip sync requirements.txt`, либо (если решим сразу в pep-621-формате) — `pyproject.toml` + `uv sync`. Для данного плана достаточно плоского `requirements.txt` ради совместимости с Docker-образом, где `uv` не обязателен.
`.gitignore`/`.dockerignore` — исключить `.venv/`.
**`requirements.txt`:**
```
streamlit>=1.42
fastapi>=0.115
uvicorn[standard]>=0.32
pydantic>=2.9
```
Streamlit зафиксирован `>=1.42` (актуальная стабильная ветка на момент планирования). Никаких HTTP-клиентов: `render_markdown` использует стандартную `urllib`. Для извлечения `<body>` в inline-превью достаточно stdlib (`html.parser`) — ту же `HTMLParser`-базу, что уже применяется в `FirstHeadingParser`. Отдельная зависимость на BeautifulSoup не нужна.
**В Docker-образе** `uv` не используется — остаёмся на `pip install --no-cache-dir -r requirements.txt`, чтобы не тащить лишний бинарь в runtime-образ. `uv` — только для локального dev-цикла.
### 5. `Dockerfile`
- Базовый образ: `python:3.12-slim`.
- Установить `tini` через apt (`apt-get update && apt-get install -y --no-install-recommends tini && rm -rf /var/lib/apt/lists/*`).
- `WORKDIR /app`, скопировать `requirements.txt`, `pip install --no-cache-dir -r requirements.txt`.
- Скопировать остальной код.
- `EXPOSE 8000 8501`.
- `ENTRYPOINT ["/usr/bin/tini", "--"]`, `CMD ["python", "start.py"]`.
- `HEALTHCHECK` — см. секцию 6b.
### 6. Запуск двух процессов — `start.py` (супервизор)
В одном контейнере два процесса — это риск (см. F-01 из ревью: упавший uvicorn, неубитые zombies, игнор сигналов PID 1). Вместо хрупкого shell-скрипта использовать маленький Python-супервизор, который:
- стартует `uvicorn` и `streamlit` через `subprocess.Popen`;
- пробрасывает `SIGTERM`/`SIGINT` обоим дочерним через `signal.signal`;
- ждёт через `os.wait()`**если любой из детей падает, супервизор убивает второго и выходит с его exit code** (контейнер корректно умирает и Docker перезапускает его по restart policy, а не живёт-зомби на одном сервисе);
- после `SIGTERM` делает graceful shutdown с таймаутом (например 10 сек), затем `SIGKILL`.
Скрипт ~40 строк, без дополнительных зависимостей. Альтернатива `tini` как init (`ENTRYPOINT ["/usr/bin/tini", "--"]`) — для reaping, но решение о fail-fast всё равно за супервизором.
Ориентир (псевдокод, финализировать при реализации):
```python
# start.py
import os, signal, subprocess, sys
procs = [
subprocess.Popen(["uvicorn", "app.api:app", "--host", "0.0.0.0", "--port", "8000"]),
subprocess.Popen(["streamlit", "run", "app/streamlit_app.py",
"--server.port", "8501", "--server.address", "0.0.0.0",
"--server.headless", "true",
"--browser.gatherUsageStats", "false"]),
]
def shutdown(signum, _frame):
for p in procs: p.terminate()
signal.signal(signal.SIGTERM, shutdown)
signal.signal(signal.SIGINT, shutdown)
pid, status = os.wait()
for p in procs:
if p.pid != pid: p.terminate()
sys.exit(os.waitstatus_to_exitcode(status))
```
`Dockerfile` использует `CMD ["python", "start.py"]` плюс `ENTRYPOINT ["tini", "--"]` (ставится через `apt-get install -y --no-install-recommends tini`) для надёжного reaping.
### 6b. Healthcheck
`HEALTHCHECK` в Dockerfile проверяет **оба** сервиса:
```dockerfile
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD python -c "import urllib.request as u; \
u.urlopen('http://127.0.0.1:8000/health', timeout=3); \
u.urlopen('http://127.0.0.1:8501/_stcore/health', timeout=3)" || exit 1
```
Если упадёт только Streamlit — healthcheck покраснеет, контейнер перезапустится (в паре с restart policy).
### 7. `.dockerignore`
Исключить `.git`, `.DS_Store`, `.claude/`, `.agents/`, `.review-sandboxes/`, `md/*.html` (сгенерированное), `__pycache__/`, `*.pyc`, `venv/`, `.venv/`.
### 8. `README.md`
Короткий блок: как собрать образ (`docker build -t md-to-html .`), как запустить (`docker run -p 8000:8000 -p 8501:8501 -e GITHUB_TOKEN=... md-to-html`), примеры `curl` для API.
## Критические файлы
- **Создать:** `app/converter.py`, `app/api.py`, `app/streamlit_app.py`, `app/__init__.py`, `requirements.txt`, `Dockerfile`, `start.py`, `.dockerignore`, `README.md`.
- **Изменить:** `md_to_html.py` (переписать на использование `app.converter.convert`).
- **Без изменений:** `md/template.html`, `md/01-01-pretask.md`.
## Проверка (verification)
1. **Локально без Docker (через uv + venv):**
- `uv venv .venv && source .venv/bin/activate && uv pip install -r requirements.txt`.
- `uvicorn app.api:app --reload``curl -X POST http://localhost:8000/convert -H 'Content-Type: application/json' -d '{"markdown":"# Hello"}'` — должна вернуться полная HTML-страница с `<title>Hello</title>`.
- `streamlit run app/streamlit_app.py` → загрузить `md/01-01-pretask.md`, нажать кнопку, убедиться что превью отрисовывается и скачивание работает.
- `GET /health``{"status":"ok"}`.
- CLI не сломался: `python md_to_html.py md/01-01-pretask.md` создаёт рядом `.html`, идентичный прежнему.
2. **Error paths API:**
- `curl -X POST .../convert -d '{"markdown":""}'``400`.
- `curl` с телом >1 МБ (поле `markdown` превышает `MAX_MARKDOWN_BYTES`) → `413` (validator).
- `curl --data-binary @big.json` >1.2 МБ общего размера → `413` (middleware).
- `curl -H "Transfer-Encoding: chunked" --data-binary @big.json` без `Content-Length``413` (middleware-ветка подсчёта байт из `receive()`).
- Невалидный JSON (отсутствует поле `markdown`) → `400` (через `RequestValidationError` handler).
- Имитация недоступности GitHub (временно подменить `API_URL` или поднять firewall rule) → `502`, контейнер не падает.
3. **Preview fallback в Streamlit:** загрузить синтетический markdown, дающий HTML >1.5 МБ — убедиться, что `link_button` скрывается и появляется `st.info` про скачивание; `download_button` при этом работает.
4. **В Docker:**
- `docker build -t md-to-html .` — собирается без ошибок.
- `docker run --rm -p 8000:8000 -p 8501:8501 md-to-html` — оба порта отвечают.
- Открыть `http://localhost:8501`, прогнать сценарий из пункта 1.
- `curl` на `http://localhost:8000/convert` работает снаружи контейнера.
5. **Supervision (F-01):**
- Внутри работающего контейнера убить uvicorn (`docker exec ... pkill -f uvicorn`) → контейнер **должен завершиться** (не остаться с одним Streamlit). `docker ps` покажет рестарт.
- `docker stop <container>` — оба процесса уходят в ≤10 сек, exit code корректный.
- `docker inspect ... | grep Health` после 30 сек — `healthy`; после kill любого сервиса — `unhealthy`.
6. **С токеном:** `docker run -e GITHUB_TOKEN=ghp_... ...` — запросы проходят, в логах нет 403/429 при нагрузке.