mirror of
https://gitverse.ru/kpa39l/vesti.git
synced 2026-09-29 09:55:03 +00:00
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:
@@ -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/` — все места обработаны
|
||||
Reference in New Issue
Block a user