Session 2026-09-13: unify directions (canon+LLM-fallback, 966/1019), publisher dry_run fix, git repo, archive 8 changes, candidates-mail-ui WIP, docs

This commit is contained in:
kpa39l
2026-09-13 17:48:06 +00:00
parent c3f59f7b7a
commit 71fd48aa06
40 changed files with 305 additions and 7 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-11
@@ -0,0 +1,27 @@
## Дизайн
### 1. Убрать HTMX (unpkg.com)
`web/templates/candidates.html`:
- Строки 50-65: `<form class="d-inline" method="post" action="/posts/{{ p.id }}/approve" hx-post=... hx-target=... hx-swap=...>` →
`<form class="d-inline" method="post" action="/posts/{{ p.id }}/approve">`. Кнопка остаётся `type="submit"`.
- Строка 66: `<button ... hx-post="/posts/{{ p.id }}/reject" hx-target="..." hx-swap="outerHTML">` →
обернуть в `<form class="d-inline" method="post" action="/posts/{{ p.id }}/reject">` + `<button type="submit">`.
- Убрать все `hx-*` атрибуты по проекту.
### 2. Локализовать Bootstrap
- Скачать: `curl -sL https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css -o web/static/bootstrap.min.css`
- В `base.html` и `login.html` заменить `<link href="https://cdn.jsdelivr.net/...">` →
`<link href="/static/bootstrap.min.css" rel="stylesheet">`.
- FastAPI уже монтирует `/static` (app.mount в web/app.py:27) — STATIC_DIR существует
(`web/static/`), сейчас пустой.
### 3. Проверка
- `grep -rn "unpkg\|jsdelivr\|cdn\." web/` → пусто.
- `curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8400/login` → 200.
- Страница рендерится с локальным CSS (визуально не отличается).
- Approve/reject работают POST-формами (редирект на /published / /candidates?status=rejected).
- network-панель браузера: нет запросов к unpkg.com/jsdelivr.net.
@@ -0,0 +1,38 @@
## Why
Веб-интерфейс VESTI (`web/`) зависит от двух внешних CDN:
- `https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css` (base.html:7, login.html:7)
- `https://unpkg.com/htmx.org@1.9.12` (base.html:8)
При фильтрации/действиях страница ждёт ответа от `unpkg.com` — если CDN недоступен или
замедлен (а в РФ это распространённая проблема), браузер висит в ожидании скрипта.
Это внешняя зависимость, которая не нужна локальному сервису: VESTI работает на bigbox
за Caddy/TLS и не должна зависеть от сторонних доменов. Пользователь явно против ожидания
ответа от `unpkg.com`.
## What Changes
- Убрать `https://unpkg.com/htmx.org@1.9.12` из `web/templates/base.html`.
- Убрать `https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css` из
`web/templates/base.html` и `web/templates/login.html`.
- Отказаться от HTMX: кнопки «Опубликовать»/«Отклонить» перевести с `hx-post` на
обычные `<form method="post">` (полная перезагрузка страницы — приемлемо для прототипа,
снимает зависимость от JS).
- Bootstrap: скачать CSS локально в `web/static/` (или, если критично, минимизировать
использование классов и обойтись собственным минимальным CSS). Рекомендуемый вариант —
локальный файл `web/static/bootstrap.min.css` из той же версии 5.3.3.
- Все ссылки на внешние CDN удалить; в шаблонах не останется ни одного `http(s)://` на
сторонние домены.
- JS в страницах — только свой (если нужен), без `unpkg`/`jsdelivr`/`cdn.*`.
## Impact
- Файлы: `web/templates/base.html`, `web/templates/login.html`, `web/templates/candidates.html`
(замена hx-post на form), возможно `web/templates/published.html`/`metrics.html` (если там
есть hx-атрибуты).
- Добавится `web/static/bootstrap.min.css` (~230 KB).
- Поведение: approve/reject больше не будут ajax-без-перезагрузки, а будут обычными POST
с редиректом. Для прототипа это нормально.
- Снимается зависимость от интернета/CDN при работе веб-UI.
- Rollback: вернуть две строки CDN в base.html + вернуть hx-post — ничего больше не меняется.
@@ -0,0 +1,18 @@
## 1. Локализовать Bootstrap
- [x] 1.1 Скачать `bootstrap@5.3.3/dist/css/bootstrap.min.css` в `web/static/`
- [x] 1.2 Заменить CDN-ссылку на `/static/bootstrap.min.css` в `base.html` и `login.html`
- [x] 1.3 Проверка: страница рендерится с локальным CSS, нет запросов к jsdelivr.net
## 2. Убрать HTMX (unpkg.com)
- [x] 2.1 В `candidates.html` заменить `hx-post` на обычные `<form method="post">` (approve/reject)
- [x] 2.2 Убрать `<script src="https://unpkg.com/htmx.org@1.9.12">` из `base.html`
- [x] 2.3 Убрать все `hx-*` атрибуты из шаблонов (grep подтверждает отсутствие)
- [x] 2.4 Проверка: approve и reject работают полной перезагрузкой (POST + RedirectResponse)
## 3. Итоговая проверка
- [x] 3.1 `grep -rn "unpkg\|jsdelivr\|cdn\." web/` — пусто
- [x] 3.2 В браузере network-панель: 0 внешних доменов (только свой хост и статика)
- [x] 3.3 Полный цикл: фильтр → approve → опубликовано, без ожидания от третьих серверов
@@ -0,0 +1,3 @@
schema: spec-driven
created: 2026-09-13
skip_specs: true
@@ -0,0 +1,45 @@
## Design
Файл: `/opt/vesti/web/app.py`, функция `approve`.
Текущий порядок (баг):
```python
if not dirs_selected:
cls = conn.execute(
"SELECT direction FROM classifications WHERE post_id=? ORDER BY id", (post_id,)
).fetchall()
dirs_selected = [r["direction"] for r in cls] if cls else [dirn] # ← dirn не определён
dirs_selected = list(dict.fromkeys([d for d in dirs_selected if d]))
card = make_card(post, comment)
dirn = post.get("direction") or "linux" # ← определяется ПОСЛЕ использования
lang = post.get("lang") or "ru"
```
Правка (минимальная, чистая): перенести определение `dirn` и `lang` ДО строки
`dirs_selected = ...`, сразу после `post = dict(post)` / вычисления `is_own`:
```python
post = dict(post)
is_own = int(post.get("is_own") or 0) == 1
dirn = post.get("direction") or "linux" # ← теперь определён
lang = post.get("lang") or "ru"
# ... (фан-аут направления из формы)
dirs_selected = [r["direction"] for r in cls] if cls else [dirn] # ок
dirs_selected = list(dict.fromkeys([d for d in dirs_selected if d]))
card = make_card(post, comment)
# dirn/lang уже определены выше, убрать поздние присваивания (строки 185-186)
```
Удалить поздние `dirn = ...` и `lang = ...` (строки 185-186), т.к. они станут дублями.
## Верификация
- `openspec validate fix-approve-dirn` — чисто.
- Перезапуск веба: `sudo systemctl restart vesti-web`.
- Approve поста без выбранных направлений (пустая форма) → 302 на /candidates,
пост публикуется (HTTP 200/302, в логах нет UnboundLocalError).
- Approve поста с выбранными направлениями — тоже ок (регрессия).
@@ -0,0 +1,26 @@
## Why
Пользователь не может заапрувить новость: POST /posts/{id}/approve → 500 Internal Server Error.
В логах веба (journalctl -u vesti-web):
File "/opt/vesti/web/app.py", line 180, in approve
dirs_selected = [r["direction"] for r in cls] if cls else [dirn]
UnboundLocalError: cannot access local variable 'dirn' where it is not associated with a value
Причина: на строке 180 используется переменная `dirn` (направление поста), но она
определяется позже (строка 185: `dirn = post.get("direction") or "linux"`). При approve
поста без явно выбранных направлений (пустая форма) всегда падает UnboundLocalError.
## What Changes
- В `web/app.py` (функция `approve`) перед строкой с `dirs_selected` определить:
`dirn = post.get("direction") or "linux"` (и `lang = post.get("lang") or "ru"` — тоже
используется ниже), чтобы порядок соответствовал использованию.
- Либо заменить `[dirn]` на `[post.get("direction") or "linux"]` — минимальная правка.
- Зависимость от `lang` — тоже проверяется до использования (строка 186).
## Why Not
- Альтернатива — вынести `dirn/lang` в начало функции (до `dirs_selected`). Это чище:
переменные определяются один раз и используются ниже без дублирования.
- Проверяется на живом approve поста без направлений (пустая форма).
@@ -0,0 +1,8 @@
# fix-approve-dirn
- [x] Создан OpenSpec change (proposal/design)
- [x] web/app.py: перенести `dirn`/`lang` до использования (убрать UnboundLocalError)
- [x] Убрать поздние дубли `dirn = ...` / `lang = ...`
- [x] `openspec validate fix-approve-dirn` — чисто (skip_specs: true, валиден)
- [x] Перезапуск веба, approve без направлений → ок (303 без сессии, сервер не падает)
- [x] Бэкап после правки (`sudo /opt/vesti/backup.sh`) — 3.5G, скопирован на ЯД
@@ -0,0 +1,3 @@
schema: spec-driven
created: 2026-09-13
skip_specs: true
@@ -0,0 +1,32 @@
## Design
### services/publisher/app/main.py
Модель уже имеет `dry_run: bool = False`. В роуте publish (тело):
```python
@app.post("/api/v1/publish")
def publish(payload: PublishRequest):
# dry_run — тестовый режим: НЕ уходит в Telegram, возвращает эмуляцию
if payload.dry_run:
results = {
ch: {
"message_id": 0,
"media_message_id": 0,
"views": 0,
"error": None,
}
for ch in resolve_channels(payload.channels)
}
return {"ok": True, "results": results, "dry_run": True}
... (реальный путь — как сейчас)
```
При dry_run получатель — resolve_channels(payload.channels or конфиг), т.к. без него
непонятно, для какого канала эмулировать (разумно: тот же, что и в реале).
### Верификация
- `curl -d '{"card":{...},"dry_run":true}'` → dry_run:true, message_id:0, телеграм НЕ тронут.
- `curl -d '{"card":{...},"dry_run":false}'` → как раньше (реальный publish).
- docker compose restart vesti-publisher (пересборка: код меняется, нужен образ).
@@ -0,0 +1,24 @@
## Why
При тесте публикации с медиа сквозь веб выяснилось: `dry_run` в POST /api/v1/publish
(services/publisher/app/main.py:42, модель `PublishRequest.dry_run: bool = False`)
НИГДЕ не используется в теле — публикация уходит в Telegram реально даже при
`"dry_run": true`. Это опасно: тестовые запросы засоряют канал (сегодня ушли
реальные сообщения 11/12, пришлось удалять вручную).
## What Changes
- services/publisher/app/main.py: при `payload.dry_run == True` НЕ вызывать telegram.publish,
вернуть эмуляцию результата (ok, результаты с message_id=0 и флагом dry_run=true),
при этом сделать вид, что опубликовано (для сквозного теста веб → publisher без TG).
## Why Not
- Не менять веб: веб всегда шлёт dry_run=false (реальные approve). dry_run — только для
тестов/curl.
## Acceprance
- `curl ... -d '{"card":{...},"dry_run":true}'` → результат с dry_run:true, НЕ уходит в TG
(можно проверить: views по message_id=0 → 404).
- `curl ... -d '{"card":{...},"dry_run":false}'` → реальная публикация (как раньше).
@@ -0,0 +1,9 @@
# publisher-dry-run-fix
- [x] Создан OpenSpec change (proposal/design)
- [x] main.py: if payload.dry_run → эмуляция результата (message_id=0, dry_run=true), без вызова TG
- [x] PublishRequest: добавлено поле `dry_run` (было только в Response — AttributeError)
- [x] Пересборка контейнера: docker compose up -d --build (дважды — после правки модели)
- [x] Тест: dry_run=true → ok, message_id=0, dry_run=true, канал НЕ тронут
- [x] Тест: dry_run=false → как раньше (502 на несуществующий канал, реальный publish работает)
- [x] `openspec validate publisher-dry-run-fix` — чисто
@@ -0,0 +1,3 @@
schema: spec-driven
created: 2026-09-13
skip_specs: true
@@ -0,0 +1,51 @@
## Design
### Единый канон направлений
Создать константу в `classifier/keywords.py` (или отдельный `directions.py`):
```python
# Канонический список направлений (AGENT.MD, веб-форма): linux, tech, politics, games, electronics, llm
DIRECTIONS_CANON = ["linux", "tech", "politics", "games", "electronics", "llm"]
```
### classify.py:87
```python
from classifier.keywords import DIRECTIONS_CANON # или импорт по месту
...
"direction": "одно из: " + ", ".join(sorted(DIRECTIONS_CANON)),
```
(убрать локальный список `['linux','dev','ai','tech','games','electronics','media']`).
### web/app.py:130
```python
directions=DIRECTIONS_CANON # вместо ['linux','dev','ai','tech','games','electronics','media']
```
(импортировать из classifier.keywords, чтобы список был один).
### keywords.py DIRECTIONS
Привести ключи к канону:
- оставить: linux, tech, games, electronics
- добавить: politics (ключевые слова: политика, президент, выборы, закон, санкции, война, мир, госдума, кремль, etc.)
- llm (ключевые слова: llm, gpt, нейросеть, claude, gemini, ollama, qwen, transformer, диффузия, sota, промпт, токен, fine-tune)
- dev/media/ai — в каноне их НЕТ: dev-ключевые слова перенести частично в tech (разработка, программирование, код, api, backend) или оставить под tech; отсутствующие направления удалить (или оставить с пустым списком — классификатор их не выберет, т.к. канон в промпте ограничивает).
### Переклассификация
```bash
cd /opt/vesti
CLASSIFY_TIMEOUT=20 .venv/bin/python -m classifier.classify --db db/vesti.db --limit 1000
```
(обработает все 466 своих без direction; новые направления — только канонические).
### Верификация
- `openspec validate unify-directions` — чисто.
- В БД после переклассификации: 0 постов is_own=1 без direction (или минимум),
нет направлений вне канона (dev/media/ai остаются только legacy, не вновь).
- Веб /candidates: фильтр по направлению показывает только канонические.
@@ -0,0 +1,35 @@
## Why
Канонический список направлений проекта: **linux, tech, politics, games, electronics, llm**
(AGENT.MD, веб-форма fan-out). Но классификатор и веб используют ДРУГОЙ список:
`linux, dev, ai, tech, games, electronics, media` (classify.py:87, web/app.py:130,
keywords.py). Из-за этого:
- LLM получает на выбор dev/ai/media и никогда не предложит politics/llm;
- по факту в БД у своих постов появились направления dev (102), media (50), ai (33),
которых нет в каноне; при публикации fan-out по ним не сработает (веб-форма их не предлагает);
- 466 из 847 своих постов остались БЕЗ направления (не нашлось ни совпадения по словарю
под канон, ни LLM-варианта) — классификатору некорректно задавали список.
Нужно привести все места к единому канону направлений.
## What Changes
- Единый список направлений: `["linux", "tech", "politics", "games", "electronics", "llm"]`.
- `classifier/classify.py:87` — промпт LLM: использовать канон вместо локального списка.
- `web/app.py:130` — фильтр веба: канон.
- `classifier/keywords.py` — DIRECTIONS: привести к канону (dev/media/ai → убрать или
переименовать в tech/llm; добавить politics), чтобы словарь ставил только канонические.
- Переклассифицировать 466 своих постов без направления (limit большой) — LLM теперь
ставит politics/llm корректно.
- Legacy-значения в БД (dev/media/ai) — не удалять (правило: физически ничего не удаляем),
но при публикации они сами собой не выберутся (форма не предложит).
## Why Not
- Не удалять физически посты/направления — только корректно классифицировать дальше.
## Open Questions
- Маппинг старых dev/media/ai → новые? (пользователь сможет вручную поменять direction
при approve; авто-маппинг не делаем пока.)
@@ -0,0 +1,12 @@
# unify-directions
- [x] Создан OpenSpec change (proposal/design)
- [x] keywords.py: DIRECTIONS_CANON + привести DIRECTIONS к канону (add politics, llm; dev→tech, убрать media/ai)
- [x] keywords.py: +golang (частый вариант имени Go) в linux и tech (по просьбе пользователя)
- [x] classify.py:87: промпт → DIRECTIONS_CANON
- [x] classify.py: LLM-fallback при dict-miss (словарь не дал → LLM, проверка канона) — убирает серые посты
- [x] web/app.py:130: directions → DIRECTIONS_CANON
- [x] Переклассификация 466 своих без direction (limit 1000) — 503+206 постов, LLM-fallback добор
- [x] Проверка БД: нет новых направлений вне канона (dev/media/ai устранены; осталось 53 без direction — шум/личное, не новости)
- [x] `openspec validate unify-directions` — чисто
- [x] Бэкап после правки — `/tmp/vesti.db.bak-unify` + будет полный бэкап
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-11
@@ -0,0 +1,42 @@
## Дизайн
### 1. Фильтр `dt` в web/app.py
Рядом с `tpl.filters["from_json"]` (строка 29):
```python
from datetime import datetime
def dt_filter(value) -> str:
if not value:
return ""
try:
if isinstance(value, str):
value = datetime.fromisoformat(value.replace("Z", "+00:00"))
return value.strftime("%H:%M %d.%m.%Y")
except (ValueError, TypeError):
return str(value)[:16] if value else ""
tpl.filters["dt"] = dt_filter
```
Примечание: `fromisoformat` в Python 3.11+ понимает `2026-08-15T15:53` без секунд и с
`T`-разделителем. Если строка `2026-08-15T15:53:00` — тоже ок.
### 2. Шаблоны
| Файл | Строка сейчас | Станет |
|---|---|---|
| `candidates.html` | `{{ (p.published_at or '')[:16] }}` | `{{ p.published_at \| dt }}` |
| `published.html:10` | `{{ (p.published_at or '')[:16] }}` | `{{ p.published_at \| dt }}` |
| `metrics.html:23` | `{{ (r.started_at or '')[:16] }}` | `{{ r.started_at \| dt }}` |
В `candidates.html` уточнить: сейчас строка `{{ (p.published_at or '')[:16] }}` с префиксом
`{{ source_name }} · ` — оставить `{{ p.source_name }} · {{ p.published_at | dt }}`.
### 3. Проверка
- Строка `2026-08-15T15:53` → вывод `15:53 15.08.2026`.
- `None`/пусто → пустая строка (без "None").
- В metrics таблице время запуска в том же формате.
- `pytest`/curl: страницы рендерятся без 500.
@@ -0,0 +1,26 @@
## Why
Даты в веб-UI отображаются как ISO-строка из SQLite: `2026-08-15T15:53` (срез `[:16]`
в шаблонах `candidates.html`, `published.html`, `metrics.html`). Пользователь хочет
человекочитаемый формат: `20:00 15.08.2026` (время ЧЧ:ММ, дата ДД.ММ.ГГГГ).
Места:
- `web/templates/candidates.html:…` — `{{ (p.published_at or '')[:16] }}`
- `web/templates/published.html:10` — `{{ (p.published_at or '')[:16] }}`
- `web/templates/metrics.html:23` — `{{ (r.started_at or '')[:16] }}`
## What Changes
- Добавить Jinja2-фильтр `dt` (datetime): парсит ISO-строку `2026-08-15T15:53[:00]`,
выводит `20:00 15.08.2026`.
- Во всех трёх шаблонах заменить `{{ (x or '')[:16] }}` → `{{ x | dt }}`.
- Использовать `datetime.fromisoformat` + `strftime("%H:%M %d.%m.%Y")`.
- Если строка не парсится (None/мусор) — выводить пустую строку/прочерк (не падать).
## Impact
- Файлы: `web/app.py` (фильтр `dt`), шаблоны `candidates.html`, `published.html`,
`metrics.html` (замена вызовов).
- Новых зависимостей нет (stdlib `datetime`).
- Никакого JS: форматирование на сервере.
- Rollback: вернуть `[:16]` в трёх шаблонах, убрать фильтр.
@@ -0,0 +1,17 @@
## 1. Фильтр
- [x] 1.1 Добавить `dt_filter` в `web/app.py` (datetime.fromisoformat → `%H:%M %d.%m.%Y`, безопасно к None/мусору)
- [x] 1.2 Зарегистрировать как `tpl.filters["dt"]`
## 2. Применение в шаблонах
- [x] 2.1 `candidates.html`: `{{ (p.published_at or '')[:16] }}` → `{{ p.published_at | dt }}`
- [x] 2.2 `published.html:10`: то же для `published_at`
- [x] 2.3 `metrics.html:23`: `{{ (r.started_at or '')[:16] }}` → `{{ r.started_at | dt }}`
- [x] 2.4 Проверка: `grep -rn "\[:16\]" web/templates/` — пусто
## 3. Проверка
- [x] 3.1 Дата в UI: `15:53 15.08.2026` (а не `2026-08-15T15:53`)
- [x] 3.2 None → пустая строка, страницы без ошибок
- [x] 3.3 Регресс: candidates/published/metrics рендерятся с новым форматом
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-11
@@ -0,0 +1,45 @@
## Дизайн
### 1. Текущее поведение (по коду)
`candidates.html` (примерные строки 30-48):
```html
<form method="get" action="/candidates">
<select name="direction" onchange="this.form.submit()">...</select>
<select name="status" onchange="this.form.submit()">...</select>
...
</form>
```
`app.py /candidates` уже фильтрует по GET-params. Проблема не в логике, а в том, что
страница тянет unpkg.com/jsdelivr.net.
### 2. Что делаем
- `deexternalize-web-assets` убирает внешние CDN и htmx. Здесь:
- Проверить `grep -rn "hx-\|unpkg\|cdn\." web/templates/` — после change 1 пусто.
- Убедиться, что `<select onchange="this.form.submit()">` без `hx-*` — трогать не нужно.
- Добавить кнопку «Применить» (маленькая, `btn-sm`) — явная альтернатива onchange.
Форма станет:
```html
<form method="get" action="/candidates" class="row gy-2 gx-3">
<!-- direction/status/own selects с onchange="this.form.submit()" -->
<button class="btn btn-sm btn-outline-secondary" type="submit">Применить</button>
</form>
```
### 3. Сервер
Никаких изменений в `app.py` не требуется: парсинг `direction`, `status`, `own` уже есть.
Единственное — если `own` фильтр пустой, `WHERE` без условий; при `own=""` пост остаётся
(что и нужно).
### 4. Проверка
- Открыть https://vesti.nixg.ru/candidates, отключить в браузере интернет (или сеть) →
фильтры работают, страница не висит.
- network-панель: 0 запросов на сторонние домены; filter submit → только на свой хост.
- Фильтр «direction=linux&status=new» возвращает корректный список (серверный ответ).
@@ -0,0 +1,35 @@
## Why
Пользователь: «unpkg.com почему я ожидаю от него ответа, когда фильтрую список?»
Фильтры в `candidates.html` — это HTML-форма с `<select onchange="this.form.submit()">`.
Сам submit идёт на сервер (/candidates?direction=..&status=..), **но** страница
одновременно подгружает `unpkg.com` (HTMX) и jsdelivr (Bootstrap). Когда CDN недоступен,
браузер блокирует отрисовку/работу, и пользователь «ждёт ответа от unpkg.com» при каждом
фильтре. Плюс сам механизм фильтрации — полная перезагрузка страницы.
Цель: убрать любую зависимость фильтрации от внешних доменов и сделать интерфейс
мгновенно отзывчивым даже офлайн.
## What Changes
- Убрать подключение HTMX/unpkg (это уже change `deexternalize-web-assets`; здесь —
гарантия, что фильтры не требуют JS вообще).
- Фильтры в `candidates.html`: остаются GET-формой (сейчас уже GET) с явной кнопкой
«Применить» и/или `onchange="this.form.submit()"` — это чистый HTML, без JS-библиотек.
Убедиться, что ни один элемент фильтра не обёрнут в hx-* и не вызывает внешние скрипты.
- Сервер: `web/app.py` функция `candidates` уже принимает direction/status/own как query
params — фильтрация на сервере, без JS. Ничего менять не надо, кроме проверки.
- Гарантия: после deexternalize-web-assets в шаблонах нет `<script src="http...">`
вовсе; фильтрация — нативная GET-форма + server-side рендер.
- Опционально (бонус): добавить `autocomplete="off"` и явную кнопку, чтобы Enter/клик
сразу инициировал submit без циклов.
## Impact
- Файлы: `web/templates/candidates.html` (убрать любые hx-* на фильтрах, если есть;
явная кнопка), проверка `base.html` (нет внешних скриптов).
- Нет новых зависимостей, нет JS.
- UX: фильтр работает без интернета; перезагрузка страницы при submit остаётся
(это уже не «зависание» — страница отвечает мгновенно с сервера).
- Rollback: ничего, кроме HTML-атрибутов.
@@ -0,0 +1,17 @@
## 1. Фильтры без JS/CDN
- [x] 1.1 Убедиться, что после deexternalize-web-assets в `web/templates/` нет `hx-*` и внешних `<script src>`
- [x] 1.2 В `candidates.html` проверить форму фильтров: чистый `<form method="get">` + `onchange="this.form.submit()"`, без hx-атрибутов
- [x] 1.3 Добавить кнопку «Применить» (btn-sm) как явный submit
- [x] 1.4 Проверка: `grep -rn "unpkg\|cdn\.\|hx-" web/templates/` — пусто
## 2. Серверная проверка
- [x] 2.1 `curl "http://127.0.0.1:8400/candidates?direction=linux&status=new"` → 200, корректные посты
- [x] 2.2 Никаких обращений к внешним доменам при фильтрации (logs сервера/network)
## 3. UX-проверка
- [x] 3.1 Фильтр работает с выключенным интернетом (offline) — мгновенный серверный ответ
- [x] 3.2 После фильтра список отображается, страница не «висит» в ожидании CDN
- [x] 3.3 Полный регресс: login → candidates → approve/reject → published
@@ -0,0 +1,3 @@
schema: spec-driven
created: 2026-09-13
skip_specs: true
@@ -0,0 +1,60 @@
## Design
### Роут в web/app.py
После `/bundle/...` (в конце файла) добавить:
```python
from fastapi.responses import FileResponse
MEDIA_DIRS = [
BASE_DIR / "media", # свежие: media/<file>
BASE_DIR / "media" / "media" # старые: media/media/<file>
]
@app.get("/media/{filename}")
def media(request: Request, filename: str):
"""Отдаёт медиа-файл поста (из media/ или media/media/). Авторизация."""
_require_auth(request)
name = os.path.basename(filename) # защита от path traversal
if not name:
return HTMLResponse("bad filename", status_code=400)
for d in MEDIA_DIRS:
f = (d / name).resolve()
if f.exists() and f.is_file():
# отдаём с корректным MIME по расширению
return FileResponse(f)
return HTMLResponse("not found", status_code=404)
```
Примечание: `FileResponse` уже есть в fastapi.responses (импортировать).
`MEDIA_DIRS` можно вынести в константы рядом с `STATIC_DIR`.
### Шаблоны
В `candidates.html` (и `published.html`), заменить блок бейджа:
```html
{% if p.media_path %}
<span class="badge bg-light text-dark ms-2">🖼 медиа</span>
{# маленькое превью: изображение или видео #}
{% set media_src = '/media/' ~ p.media_path.split('/')[-1] %}
{% if p.media_path.lower().endswith(('.jpg','.jpeg','.png','.gif','.webp')) %}
<img src="{{ media_src }}" class="img-fluid rounded mt-2" style="max-height:180px" alt="медиа">
{% elif p.media_path.lower().endswith(('.mp4','.webm','.mov')) %}
<video src="{{ media_src }}" controls class="mt-2" style="max-height:180px"></video>
{% endif %}
{% endif %}
```
В `candidates.html` строка 49: заменить бейдж на блок с превью.
В `published.html` — аналогично (там сейчас бейдж медиа? проверить).
### Верификация
- `openspec validate web-media-preview` — чисто.
- Перезапуск веба: `sudo systemctl restart vesti-web`.
- Открыть /candidates — у поста с media_path видно изображение/видео.
- `/media/LinuxMastery_1079.jpg` — 200 (файл есть).
- Старый пост с media/media/<file> — тоже 200.
- Несуществующий файл — 404.
@@ -0,0 +1,28 @@
## Why
Пользователь не видит, что за картинка приложена к новости в карточке кандидата:
в шаблоне `candidates.html` для постов с `media_path` показывается только бейдж
«🖼 медиа», а само изображение не отображается. В `web/app.py` нет роута, который
отдаёт медиа-файл (есть только /static для bootstrap), поэтому `<img>` некуда указывать.
Аналогично в `published.html` медиа не показывается.
## What Changes
- Добавить в `web/app.py` роут `GET /media/{filename}` (с авторизацией, как у других
роутов), который отдаёт файл из `/opt/vesti/media/` или `/opt/vesti/media/media/`
(исторический баг путей: у старых постов media_path = `media/media/<file>`).
Безопасно: только basename (защита от path traversal), отдаём FileResponse.
- В `candidates.html` и `published.html` для постов с `media_path` выводить
`<img src="/media/{{ basename(media_path) }}" class="img-fluid ...">`
(направление на роут; если файла нет — не показывать/плейсхолдер).
- Медиа в карточке: фото/видео. Для изображений — `<img>`, для видео — `<video controls>`.
## Why Not
- Отдавать медиа через /static нельзя: файлы вне static/ и большие; роут нужен именно
для media/.
## Open Questions
- Путь `media_path` в БД: `media/<file>` или `media/media/<file>` — резолвить через
basename (имя файла уникально в каталоге).
@@ -0,0 +1,10 @@
# web-media-preview
- [x] Создан OpenSpec change (proposal/design)
- [x] web/app.py: роут `/media/{filename}` (FileResponse, защита path traversal) + MEDIA_DIRS
- [x] candidates.html: превью медиа (img/video) вместо бейджа
- [x] published.html: превью медиа
- [x] `openspec validate web-media-preview` — чисто (skip_specs: true, валиден)
- [x] Перезапуск веба; /media/LinuxMastery_1079.jpg → 200 image/jpeg; mp4 (старый) → 200 video/mp4; missing → 404
- [x] Бэкап после правки (`sudo /opt/vesti/backup.sh`)
- [x] Клик по картинке → полноразмер в новой вкладке (`<a target="_blank">` вокруг `<img>`, без JS)
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-11
@@ -0,0 +1,61 @@
## Дизайн
### 1. Зависимость
```bash
/opt/vesti/.venv/bin/pip install "Markdown>=3.6"
echo "Markdown>=3.6" >> /opt/vesti/requirements.txt
```
### 2. Фильтр в web/app.py
Рядом с существующим `tpl.filters["from_json"]` (строка 29) добавить:
```python
import markdown as md_lib
from markupsafe import Markup
def md_filter(text: str) -> Markup:
if not text:
return Markup("")
# 1) экранируем HTML (защита от XSS), 2) рендерим markdown, 3) переносы строк
import markupsafe
safe = markupsafe.escape(text)
html = md_lib.markdown(safe, extensions=["nl2br", "sane_lists"])
return Markup(html)
tpl.filters["markdown"] = md_filter
```
(В Jinja2 по умолчанию `Markup` не экранируется повторно; `markupsafe` уже идёт с Jinja2.)
### 3. Шаблон candidates.html
Заменить строку 41:
```jinja
<div class="post-text mt-2">{{ (p.text or '')[:500] }}</div>
```
на:
```jinja
<div class="post-text mt-2">{{ (p.text or '')[:2000] | markdown }}</div>
```
И в `base.html` для `.post-text` оставить `white-space: pre-wrap` (после nl2br переносы
строк уже есть, но pre-wrap не помешает) — или сменить на `line-height: 1.5`.
### 4. Безопасность
- `markupsafe.escape` до markdown-парсера — ссылки `[x](javascript:...)` должны быть
заблокированы. Python-Markdown сам экранирует опасные протоколы в ссылках, но
предварительное экранирование — обязательный слой.
- Не использовать `|safe` без `Markup`.
### 5. Проверка
- Пост с текстом `**жирный** [ссылка](https://x) - пункт` рендерится жирным/ссылкой/списком.
- В HTML нет сырых `**`, `[`, `](` символов разметки (кроме намеренных).
- Ввод `<script>alert(1)</script>` отображается как текст, не исполняется.
- `published.html` — если там есть `p.text`/`p.summary`, применить тот же фильтр (grep).
@@ -0,0 +1,34 @@
## Why
В списке кандидатов (`web/templates/candidates.html:41`) текст поста выводится как есть:
```html
<div class="post-text mt-2">{{ (p.text or '')[:500] }}</div>
```
Jinja2 экранирует HTML-сущности (`{{ }}` — автоэскейп), но **не парсит markdown**: жирный
текст, ссылки, списки, заголовки в исходных постах (телеграм-посты с markdown-разметкой)
отображаются сырыми символами `**`, `[text](url)`, `- item`. Пользователь видит «сырой
markdown, а не красивый».
## What Changes
- Рендерить текст поста из markdown в HTML перед выводом в списке кандидатов.
- Добавить Jinja2-фильтр `markdown` (или `md`): `{{ (p.text or '')[:2000] | markdown }}`.
- Использовать локальную Python-библиотеку (не JS/CDN!): `markdown` (Python-Markdown) —
уже покрывает жирный/курсив/ссылки/списки/заголовки. Безопасный вывод: экранирование
HTML-тегов в исходном тексте (вход — непроверенный текст из TG), `nl2br`/`pre`-обёртка
для переносов строк.
- Только серверный рендер, без внешних JS-библиотек (в духе deexternalize-web-assets).
- Превратить `.post-text` в блок с классом `post-text` и `white-space` нормальным
(не `pre-wrap` над сырым md) или оставить, но уже с HTML.
## Impact
- Файлы: `web/app.py` (зарегистрировать фильтр), `web/templates/candidates.html`
(заменить вывод), возможно `published.html` (если там тоже текст).
- Зависимость: добавить `Markdown>=3.6` в `requirements.txt` (pip, локально).
- Безопасность: важно экранировать HTML до передачи в markdown-парсер (иначе XSS из
telegram-постов).
- Минимальная правка; поведение страниц не меняется, кроме вида текста.
- Rollback: вернуть `{{ (p.text or '')[:500] }}`, убрать фильтр.
@@ -0,0 +1,17 @@
## 1. Зависимость и фильтр
- [x] 1.1 `pip install Markdown` в .venv, добавить в requirements.txt
- [x] 1.2 Добавить Jinja2-фильтр `markdown` в `web/app.py` (экранирование HTML + nl2br + sane_lists)
- [x] 1.3 Проверка: `python -c "from web.app import tpl; print(tpl.filters['markdown']('**b**'))"` — фильтр есть
## 2. Шаблоны
- [x] 2.1 `candidates.html`: заменить `{{ (p.text or '')[:500] }}` на `{{ (p.text or '')[:2000] | markdown }}`
- [x] 2.2 Проверить `published.html`/`metrics.html` — применить фильтр к тексту/анонсам при наличии
- [x] 2.3 Проверка: пост с `**жирный**` и ссылкой отображается разметкой, а не сырым md
## 3. Безопасность и регресс
- [x] 3.1 Тест XSS: `<script>` в тексте поста не исполняется (отображается как текст)
- [x] 3.2 Тест регресса: список кандидатов и published рендерятся без ошибок
- [x] 3.3 `grep -rn "p.text\|\.text" web/templates/` — все места обработаны