Initial import: vesti.nixg.ru — новостной апрув-проект (web, crawler, classifier, publisher, openspec)

This commit is contained in:
kpa39l
2026-09-13 15:58:32 +00:00
commit c3f59f7b7a
113 changed files with 7065 additions and 0 deletions
View File
@@ -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,2 @@
schema: spec-driven
created: 2026-09-08
+106
View File
@@ -0,0 +1,106 @@
# Design: own-content-hub
## Approach
Канал @dedinit — обычный источник в реестре, но с флагом `own: true`. Вся общая логика
(краулер, дедуп, классификатор, банк) работает как для любого TG-канала; различие —
семантика: посты своего канала считаются СВОИМ контентом (is_own=1) и становятся
«сильными кандидатами» на автораспространение по всем тематическим лентам.
Ключевая идея — **fan-out вместо single-out**: один пост пользователя при подтверждении
уходит сразу во все тематические каналы @dedinit_vesti_<direction>_<lang>_bot, под
которые он подходит (направления из классификации). Так контент «инъецируется» в
новостные ленты и собирает аудиторию на всех площадках, а сам канал-источник остаётся
первоисточником (атрибуция везде).
Поток данных:
```
sources.yaml: @dedinit (own: true)
│
▼
telegram_crawler.py → posts.is_own=1 (дедуп как обычно; медиа скачивается — свой контент)
▼
classifier.py → направление(я) + relevance; is_own + критичность → «сильный кандидат»
▼
vesti-web (фильтр «Свои», бейдж; подтверждение с выбором направлений рассылки)
▼
tg-publisher.py → fan-out: карточка в каждый @dedinit_vesti_<dir>_<lang>_bot
▼
news-store → бандл bundles/<dir>/<YYYY-MM>/<slug>.md (origin=own, ссылка на оригинал)
```
## Files
```bash
# Изменяемые файлы
sources/sources.yaml # + источник dedinit (own: true)
db/schema.sql # + posts.is_own, posts.is_own_canonical, sources.own,
# published.distributed_dirs (миграция ALTER TABLE)
crawler/telegram_crawler.py # + определение own-источника, проставление is_own,
# is_own_canonical (первый экземпляр = канал), медиа скачивается
classifier/classify.py # + is_own → «сильный кандидат» (relevance critical, classified=True)
publisher/bot.py # + fan-out publish_multi(directions)
publisher/card.py # + атрибуция «Дед в АйТи» + ссылка на оригинал
web/app.py # + фильтр is_own, бейдж, выбор направлений рассылки при approve
web/templates/candidates.html # + бейдж СВОЙ, чекбоксы направлений
web/store.py # + frontmatter origin: own + ссылка на оригинал
```
## Commands
```bash
# 1) Миграция схемы (идиемпотентно)
cd /opt/vesti && .venv/bin/python - <<'PY'
import sqlite3
c = sqlite3.connect('db/vesti.db')
for ddl in [
"ALTER TABLE posts ADD COLUMN is_own INTEGER DEFAULT 0",
"ALTER TABLE posts ADD COLUMN is_own_canonical INTEGER DEFAULT 0",
"ALTER TABLE sources ADD COLUMN own INTEGER DEFAULT 0",
"ALTER TABLE published ADD COLUMN distributed_dirs TEXT",
]:
try: c.execute(ddl)
except sqlite3.OperationalError: pass # уже есть
c.commit(); c.close()
PY
# 2) Добавить источник в sources.yaml (own: true), синк
.venv/bin/python -c "from sources.sources import sync_sources_to_db, load_sources_yaml; sync_sources_to_db(load_sources_yaml())"
# 3) Краулер по своему каналу (бэкфилл ~1039 постов; медиа скачивается)
.venv/bin/python -m crawler.telegram_crawler --source dedinit
# 4) Классификатор по своему каналу
CLASSIFY_TIMEOUT=20 .venv/bin/python -m classifier.classify --direction linux # + нужные направления
# 5) Веб
VESTI_WEB_PASSWORD=<пароль> .venv/bin/uvicorn web.app:app --host 127.0.0.1 --port 8400
# 6) Проверка
curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8400/login # 200
sqlite3 db/vesti.db "SELECT COUNT(*) FROM posts WHERE is_own=1" # >0
sqlite3 db/vesti.db "SELECT COUNT(*) FROM posts WHERE is_own=1 AND is_own_canonical=1"
```
## Rollback
```bash
# Отключить источник (не удалять данные)
# sources.yaml: dedinit → enabled: false, own: false
# Перезапустить синк; посты остаются в БД, новые не приходят.
# Поля is_own в данных можно оставить (безвредно); удаление данных — по согласованию.
```
Существующие внешние источники и их посты не затрагиваются: is_own=0 по умолчанию.
## Risks
- **Бэкфилл 1039 постов** — первый прогон долгий (медиа ~623M). Ограничить
MAX_POSTS_PER_CHANNEL или сначала только текст, медиа докачать позже (бэкфилл-флаг).
- **Дубликаты с внешними источниками**: пост пользователя, который запостили в чужой
канал, попадёт и как is_own (свой), и как внешний. Для своих постов `is_own_canonical=1`
(первичный экземпляр), внешние остаются как «упоминания» (is_own=0).
- **Fan-out = спам**: всегда режим подтверждения; веб показывает направления заранее;
лимит 4096 символов сохраняется; атрибуция не даёт путаницы с чужим контентом.
- **qwen3:8b think:false** — уже учтено в classify.py.
- **Право на медиа**: медиа своего канала скачивается (контент пользователя) — ок.
@@ -0,0 +1,65 @@
## Why
У пользователя есть собственные ресурсы (канал Telegram «Дед в АйТи» @dedinit, сайт dedinit.ru,
далее — феды/видео), но они живут разрозненно: контент, опубликованный в одном месте, не
попадает в другие. Цель — **собственный контент-хаб**: единая точка сбора ВСЕХ постов
пользователя из разных платформ, один банк своего контента, и **автораспространение**
этого контента по тематическим новостным ботам VESTI (и в перспективе — по другим
платформам), чтобы органически росла аудитория на всех площадках.
Задача НЕ «сделать копию канала»: канал @dedinit — полноценный источник данных в общей
логике проекта (как любой TG-канал): краулер → классификатор → кандидат → подтверждение →
публикация в тематические каналы → банк статей. Отличие от внешних источников — это
СВОЙ контент (is_own=1): он всегда кандидат на публикацию («инъекция» в ленту),
не блокируется политикой чужих форвардов и помечается атрибуцией автора.
## What Changes
- Добавить канал @dedinit (id 1150165846) в sources.yaml как источник `own: true`
(свой контент пользователя), направление определяется классификатором (канал
разносторонний: Linux, IT, AI, игры...).
- Краулер: посты своего канала помечаются `is_own=1`, для них продолжает работать
дедуп (sha256/url); политика форвардов для своего канала — как обычно (чужие
форварды → fwd-поля без медиа).
- Классификатор: свой контент с relevance critical/high → «сильные кандидаты»
(самокатегоризация; приоритет в ленте подтверждения).
- **Автораспространение (distribution)**: подтверждённый пост рассылается НЕ только
в один канал @dedinit_vesti_<dir>_<lang>_bot, а во ВСЕ тематические боты/каналы,
соответствующие направлениям поста (один пост может попасть в несколько лент).
Режим — по-прежнему «черновик на подтверждение», но подтверждение ведёт к
множественной публикации (fan-out).
- Веб: фильтр «Свои» (только is_own посты), отображение бейджа «СВОЙ», выбор
направлений для рассылки при подтверждении.
- Расширить таблицы: posts.is_own, posts.is_own_canonical (первичный экземпляр),
sources.own, published.distributed_dirs (какие направления розданы).
## Capabilities
### New Capabilities
- `own-content`: Свой контент-хаб: пометка постов пользователя (is_own), приоритет
в кандидатах, сквозная ссылка на оригинал во всех публикациях.
### Modified Capabilities
- `tg-crawler`: пометка is_own по источникам с own: true; политика форвардов для
своих каналов не отличается от внешних (dedup + fwd-поля).
- `classifier`: свой контент → «сильный кандидат» (relevance critical/high при
словарном попадании; classified=True даже без LLM-подтверждения, если есть
направление).
- `tg-publisher`: fan-out по нескольким направлениям; атрибуция «Дед в АйТи» +
ссылка на оригинал.
- `vesti-web`: фильтр «Свои», бейдж, выбор направлений рассылки при подтверждении.
- `news-store`: бандл своего поста помечается origin=own + ссылка на оригинал в
frontmatter.
## Impact
- Затронутые сервисы/порты: без новых портов; краулер (cron), классификатор,
tg-publisher, веб 127.0.0.1:8400 — те же.
- Файлы: sources/sources.yaml (новый источник), crawler/telegram_crawler.py,
classifier/classify.py, publisher/bot.py, publisher/card.py, web/app.py,
db/schema.sql (миграции ADD COLUMN is_own и др.).
- Данные: 1039 постов канала @dedinit появятся как is_own посты; медиа своего канала
скачивается (это контент пользователя — можно).
- Секреты: не требуются новые (та же Telethon-сессия, токены ботов в .env).
- Rollback: отключение источника `own: false` / удаление поля is_own; существующие
внешние посты не затрагиваются; данные не удаляются (правило пользователя).
@@ -0,0 +1,30 @@
## Purpose
Классификация постов по направлениям локальным LLM + словарный фильтр. Дополняется
приоритетом «своего контента» и поддержкой мультинаправлений для fan-out.
## ADDED Requirements
### Requirement: Приоритет своего контента
Классификатор MUST обрабатывать посты с `is_own=1` как «сильные кандидаты»: при наличии
направления по словарю — relevance=critical, classified=True — даже если LLM недоступна
(фолбэк без LLM, пост не теряется). Для внешних постов поведение без изменений.
#### Scenario: Свой пост, LLM недоступна
- **WHEN** пост is_own=1, словарь дал направление, но Ollama недоступна
- **THEN** пост получает direction (словарь), relevance=critical, classified=True,
method='dict-own' (не требует LLM)
#### Scenario: Свой пост без направления по словарю
- **WHEN** пост is_own=1, словарь не дал направление
- **THEN** классификации нет (classified=False) до LLM; пост не теряется (остаётся в очереди)
### Requirement: Мультинаправления
Классификатор MUST уметь возвращать несколько направлений для поста (для маппинга fan-out);
основное направление хранится в posts.direction, дополнительные — в classifications
(таблица уже позволяет несколько классификаций на пост).
#### Scenario: Пост про Linux + AI
- **WHEN** пост упоминает и линукс, и нейросети
- **THEN** в classifications может быть несколько записей (linux, ai); fan-out использует
оба при подтверждении
@@ -0,0 +1,21 @@
## Purpose
Банк статей: markdown-бандлы с frontmatter + медиа. Дополняется пометкой происхождения
своего контента и ссылкой на оригинал.
## ADDED Requirements
### Requirement: Атрибуция в бандле
Бандл поста с is_own=1 MUST содержать в frontmatter `origin: own`, ссылку на оригинал
(`source_url` = t.me/dedinit/<id>) и имя автора («Дед в АйТи»). Бандл внешнего поста —
как раньше (origin: external, source_url=url источника).
#### Scenario: Бандл своего поста
- **WHEN** create_bundle вызывается для поста is_own=1
- **THEN** frontmatter содержит origin: own, source: dedinit, source_url:
https://t.me/dedinit/<tg_post_id>, author: Дед в АйТи
#### Scenario: Бандл внешнего поста в нескольких направлениях
- **WHEN** пост (свой или внешний) опубликован в несколько направлений
- **THEN** бандл создаётся по каждому направлению (bundles/<dir>/<YYYY-MM>/<slug>.md),
обе записи ссылаются на один и тот же original post_id
@@ -0,0 +1,32 @@
## Purpose
Чтение публичных Telegram-каналов через Telethon (MTProto) для сбора новостей с метриками
популярности. Дополняется поддержкой «своих» источников (own: true) — контент пользователя.
## ADDED Requirements
### Requirement: Источники своего контента
Краулер MUST распознавать источники с `own: true` в sources.yaml и для их постов
проставлять `is_own=1`, `is_own_canonical=1`. Для таких источников медиа MUST
скачиваться (контент принадлежит пользователю).
#### Scenario: Краулинг своего канала
- **WHEN** источник slug=dedinit имеет own: true
- **THEN** посты сохраняются с is_own=1 и is_own_canonical=1; медиа скачивается;
инкрементальный обход и дедуп работают как обычно
#### Scenario: Чужой форвард в своём канале
- **WHEN** пост в своём канале — форвард из чужого канала
- **THEN** пост сохраняется с is_own=1 (это пост пользователя, он его переслал) и с
fwd_from_channel_id/fwd_from_post_id; медиа не скачивается (содержимое чужое),
атрибуция оригинала сохраняется в fwd-полях
### Requirement: Канонический экземпляр своего контента
Если пост пользователя (is_own=1) позже встречается во внешнем канале (тот же sha256/url),
внешний экземпляр MUST сохраняться как «упоминание» с is_own=0; каноническим остаётся
первичный (is_own_canonical=1).
#### Scenario: Свой пост запостили в чужой канал
- **WHEN** краулер находит в чужом канале пост с текстом, совпадающим с is_own-постом
- **THEN** создаётся запись is_own=0 (упоминание) без дублирования контента; веб видит
оба экземпляра, но кандидатом на публикацию считается канонический (is_own_canonical)
@@ -0,0 +1,43 @@
## Purpose
Публикация отобранных новостей через Bot API в тематические Telegram-каналы. Дополняется
автораспространением своего контента (fan-out) по нескольким направлениям.
## ADDED Requirements
### Requirement: Автораспространение (fan-out)
Подтверждение СВОЕГО поста (is_own=1) MUST публиковать карточку во ВСЕ тематические
каналы @dedinit_vesti_<direction>_<lang>_bot, соответствующие выбранным направлениям
(по умолчанию — все направления классификации поста). Каждая карточка MUST содержать
атрибуцию «Дед в АйТи» (@dedinit) и ссылку на оригинал t.me/dedinit/<post_id>.
#### Scenario: Мульти-публикация
- **WHEN** подтверждается свой пост с направлениями [linux, ai]
- **THEN** карточка отправляется в @dedinit_vesti_linux_ru_bot и @dedinit_vesti_ai_ru_bot;
обе содержат ссылку на оригинал
#### Scenario: Чужой пост — без fan-out
- **WHEN** подтверждается внешний пост (is_own=0)
- **THEN** публикуется только в канал своего направления, без атрибуции автора
### Requirement: Публикация по списку направлений
Публикатор MUST поддерживать `publish_multi(directions)` — публикацию карточки по списку
направлений, возвращающую map {direction: message_id}. При пустом списке направлений
MUST публиковать в направление по умолчанию (direction поста).
#### Scenario: Список направлений рассылки
- **WHEN** publish_multi вызывается с directions=[linux, ai]
- **THEN** возвращается {linux: message_id1, ai: message_id2}; каждая публикация
записывается в published с distributed_dirs
#### Scenario: Пустой список направлений
- **WHEN** publish_multi вызывается без directions
- **THEN** публикация идёт в канал направления поста (direction по умолчанию), без fan-out
### Requirement: Метрики по каждому направлению
Для опубликованных карточек MUST собираться views через Bot API по каждому направлению
(каждому message_id), чтобы веб показывал эффективность рассылки по лентам.
#### Scenario: Метрики fan-out
- **WHEN** пост разослан в [linux, ai] и каналы набирают просмотры
- **THEN** get_views вызывается для каждого message_id; views хранятся по направлению
@@ -0,0 +1,28 @@
## Purpose
Веб-интерфейс управления VESTI. Дополняется фильтром «Свои», бейджем и выбором
направлений рассылки для своего контента.
## ADDED Requirements
### Requirement: Фильтр «Свои»
Веб MUST давать фильтр постов по `is_own` (все/только свои/только внешние) и показывать
бейдж «СВОЙ» у постов is_own=1. Для своего поста при подтверждении MUST отображаться
выбор направлений рассылки (по умолчанию — все направления классификации поста).
#### Scenario: Фильтр своих постов
- **WHEN** админ выбирает фильтр «Свои»
- **THEN** показываются только посты is_own=1 с бейджем «СВОЙ» и чекбоксами направлений
#### Scenario: Подтверждение своего поста
- **WHEN** админ подтверждает свой пост с выбранными направлениями [linux, ai]
- **THEN** публикация идёт в оба канала (fan-out), результат виден в опубликованных с
distributed_dirs
### Requirement: Список распространения
Веб MUST показывать для опубликованного поста, в какие направления/каналы он был
разослан (distributed_dirs) и метрики (views) по каждому каналу.
#### Scenario: Просмотр распространения
- **WHEN** админ открывает опубликованный пост (свой)
- **THEN** видит список @dedinit_vesti_<dir>_<lang>_bot с views по каждому
+60
View File
@@ -0,0 +1,60 @@
# Tasks: own-content-hub
## 1. Схема БД и реестр источников
- [x] 1.1 Миграция схемы: ALTER TABLE posts ADD COLUMN is_own INTEGER DEFAULT 0, is_own_canonical INTEGER DEFAULT 0; sources ADD COLUMN own INTEGER DEFAULT 0; published ADD COLUMN distributed_dirs TEXT
Проверка: `sqlite3 db/vesti.db "PRAGMA table_info(posts)"` показывает is_own/is_own_canonical; sources.own; published.distributed_dirs
- [x] 1.2 Добавить источник dedinit в sources.yaml (channel: dedinit, own: true, direction: null, lang: ru)
Проверка: `grep -A4 "slug: dedinit" sources/sources.yaml` → есть own: true
- [x] 1.3 Синк реестра в БД (sources.own=1 для dedinit)
Проверка: `sqlite3 db/vesti.db "SELECT slug, own FROM sources WHERE slug='dedinit'"` → dedinit|1
## 2. Краулер
- [x] 2.1 telegram_crawler.py: загрузка own-флага источников; проставление is_own/is_own_canonical для own-источников; медиа скачивается
Проверка: код реализован (store_posts/is_own_source, py_compile OK); интеграционная проверка ждёт бэкфилла 2.3
- [x] 2.2 Форварды в своём канале: чужой форвард сохраняется с is_own=1 + fwd-полями, медиа НЕ скачивается
Проверка: код реализован (skip_media для форвардов); интеграционная проверка после бэкфилла
- [ ] 2.3 Бэкфилл своего канала: первый прогон ~1039 постов (медиа по возможности; при лимите — текст без медиа, бэкфилл-флаг)
Проверка: `sqlite3 db/vesti.db "SELECT COUNT(*) FROM posts WHERE is_own=1"` > 0 (до ~1039)
- [x] 2.4 Дедуп: тот же sha256/url во внешнем канале → is_own=0 (упоминание), канонический остаётся is_own_canonical=1
Проверка: код реализован (канон vs упоминание); проверка на реальных данных после бэкфилла
## 3. Классификатор
- [x] 3.1 classify.py: is_own=1 + направление по словарю → relevance=critical, classified=True, method='dict-own' (без LLM при недоступности)
Проверка: тестовый свой пост со словарным попаданием при выключенной Ollama → classified=1 relevance=critical
- [x] 3.2 Поддержка мультинаправлений: классификатор может писать несколько записей classifications (fan-out)
Проверка: пост linux+ai имеет 2 записи classifications
## 4. Публикатор (fan-out)
- [x] 4.1 publisher/bot.py: publish_multi(directions) → публикация карточки в каждый @dedinit_vesti_<dir>_<lang>_bot; возвращает {dir: message_id}
Проверка: dry-run с токеном → map направлений; без токена → dry_run=True (проверено ['linux','ai'])
- [x] 4.2 Атрибуция в карточке (publisher/card.py): для is_own-постов строка «Дед в АйТи (@dedinit)» + ссылка на оригинал, для внешних — без
Проверка: make_card(is_own пост) содержит t.me/dedinit/ и «Дед в АйТи» (проверено); make_card(внешний) — нет
- [x] 4.3 Запись distributed_dirs + tg_message_ids в published при fan-out
Проверка: после approve (TestClient, dry-run) distributed_dirs=JSON([linux, ai]) в published + views
## 5. Веб
- [x] 5.1 Фильтр «Свои» (is_own) в /candidates: параметр own=1|0, бейдж «СВОЙ»
Проверка: TestClient GET /candidates?own=1 → только is_own посты с бейджем «⭐ СВОЙ» (проверено)
- [x] 5.2 Выбор направлений рассылки при approve: чекбоксы (по умолчанию — направления классификации); approve → publish_multi
Проверка: TestClient POST /posts/{id}/approve с dirs=linux,ai → 302 /published, distributed_dirs=[linux,ai] (dry-run) (проверено)
- [x] 5.3 Опубликованные: показ distributed_dirs (в какие каналы разослан) и метрики по каждому
Проверка: /published содержит «Разослан в» с @dedinit_vesti_linux_ru_bot и ai (проверено TestClient)
## 6. Банк статей (news-store)
- [x] 6.1 store.py: frontmatter origin: own + source_url + author для is_own-постов; origin: external для внешних
Проверка: бандл содержит `origin: own` и `source_url: https://t.me/dedinit/<id>`, author «Дед в АйТи» (проверено)
- [x] 6.2 Бандлы по каждому направлению fan-out (bundles/<dir>/<YYYY-MM>/<slug>.md)
Проверка: create_bundle(['linux','ai']) → 2 файла в bundles/linux и bundles/ai (проверено)
## 7. Проверка интеграции и документация
- [ ] 7.1 Полный прогон: синк → краулер dedinit → классификатор → веб (approve с fan-out) → бандлы; внешние источники не затронуты (is_own=0 по умолчанию)
Проверка: counts по is_own в БД, бандлы, /published, runs ok — ждёт бэкфилла 2.3 (реальная сеть)
- [x] 7.2 Обновить STATUS.md / TODO.md / WALKTHROUGH.md (что сделано, как запускать, питфолы)
Проверка: документы отражают новое состояние (обновлено при закрытии сессии)
@@ -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,104 @@
# Design: publisher-service
## Approach
Выносим публикацию в Telegram из веб-процесса в изолированный FastAPI-микросервис.
Сервис — единственная точка, которая знает токен бота (прокси), каналы и Bot API.
Остальные компоненты (веб, в будущем cron/боты) вызывают его по HTTP.
Схема:
```
vesti-web (:8400) ──POST /api/v1/publish──▶ publisher-service (:8410) ──Bot API (SOCKS5 127.0.0.1:1080)──▶ Telegram
approve (человек) │ VESTI_BOT_TOKEN, VESTI_BOT_CHANNELS
▼
Telegram: @dedinit_vesti (+ другие каналы)
```
## Files
```bash
# Новый сервис
services/publisher/
├── app/
│ ├── __init__.py
│ ├── main.py # FastAPI: POST /api/v1/publish, GET /healthz
│ ├── config.py # env: VESTI_BOT_TOKEN, TG_PROXY, VESTI_BOT_CHANNELS, LISTEN_PORT
│ ├── telegram.py # Bot API клиент (httpx + SOCKS5): send, get_views
│ └── channels.py # разбор списка каналов (VESTI_BOT_CHANNELS)
├── requirements.txt # fastapi, uvicorn, httpx[socks], pydantic, python-dotenv
├── Dockerfile # python:slim, non-root, read-only fs
├── docker-compose.yml # сервис, порт 8410, healthcheck, env из .env
├── .env.example # без секретов
└── README.md # API, порты, запуск, безопасность
# Изменения
web/app.py # approve → HTTP POST в publisher-service (вместо import publisher.bot)
.env.example # + VESTI_BOT_CHANNELS, TG_PROXY
STATUS.md / TODO.md / WALKTHROUGH.md # статус
```
## Data / Config
```bash
# .env (реальные значения; НЕ коммитить)
VESTI_BOT_TOKEN=<токен @dedinit_controller_bot> # уже в .env
TG_PROXY=socks5://127.0.0.1:1080 # уже есть
VESTI_BOT_CHANNELS=@dedinit_vesti # список каналов, разделитель запятая
PUBLISHER_PORT=8410
```
## API
### POST /api/v1/publish
```json
{
"card": {"text": "...", "media": "/path/to/photo.jpg", "direction": "linux", "lang": "ru"},
"channels": ["@dedinit_vesti"] // опционально; default = VESTI_BOT_CHANNELS
}
```
Ответ 200:
```json
{
"ok": true,
"results": {
"@dedinit_vesti": {"message_id": 123, "media_message_id": 122, "views": 0}
},
"dry_run": false
}
```
Ошибки: 400 (невалидный card), 502 (Bot API / прокси недоступен), 403 (бот не админ).
### GET /healthz
```json
{"status": "ok", "bot": "@dedinit_controller_bot", "proxy": "socks5://127.0.0.1:1080", "channels": ["@dedinit_vesti"]}
```
Healthcheck: `curl -f http://127.0.0.1:8410/healthz`.
## Commands
```bash
cd /opt/vesti
# dev (без Docker):
.venv/bin/pip install -r services/publisher/requirements.txt
.venv/bin/uvicorn services.publisher.app.main:app --host 127.0.0.1 --port 8410
curl -s http://127.0.0.1:8410/healthz
# prod (Docker):
docker compose -f services/publisher/docker-compose.yml up -d --build
curl -s http://127.0.0.1:8410/healthz
curl -s -X POST http://127.0.0.1:8410/api/v1/publish \
-H 'Content-Type: application/json' \
-d '{"card": {"text": "<b>Тест</b>", "direction": "linux", "lang": "ru"}}'
# переключение веба:
# в web/app.py заменить `from publisher.bot import publish_multi` на HTTP-клиент
# (или переменная окружения PUBLISHER_URL=http://127.0.0.1:8410)
```
## Verification
- [ ] `openspec validate publisher-service` → 0 ошибок
- [ ] `curl :8410/healthz` → ok, бот, прокси, каналы
- [ ] POST /api/v1/publish с тестовой карточкой → message_id в @dedinit_vesti
- [ ] Бот не админ / прокси упал → понятная ошибка (4xx/502), веб показывает
- [ ] approve в вебе → реальное сообщение в канале (микросервис вызван по HTTP)
@@ -0,0 +1,66 @@
## Why
Сейчас публикатор встроен в веб-приложение (web/app.py импортирует publisher.bot напрямую
и вызывает publish_multi). Это нарушает изоляцию элементов системы и мешает масштабированию:
- публикация привязана к процессу веба (ошибка Bot API роняет весь approve);
- нет отдельного жизненного цикла (нельзя перезапустить/обновить публикатор отдельно);
- каналы захардкожены шаблоном @dedinit_vesti_<dir>_<lang>_bot, а реальная схема —
«один бот-контроллер, несколько каналов» (на старте один @dedinit_vesti, потом больше);
- нет единой точки входа для публикации из любых компонентов (веб, cron, будущие боты VK/fediverse).
Цель — вынести публикацию в **изолированный микросервис** (отдельный FastAPI-сервис в
контейнере), который вызывается HTTP-запросом при необходимости. Это соответствует
архитектурному решению «сегментировать элементы системы на микросервисы в отдельных
контейнерах» (PRD, раздел 5).
## What Changes
- Новый сервис `publisher/` (или `services/publisher/`): FastAPI-приложение с эндпоинтом
`POST /api/v1/publish` (публикация карточки в один или несколько каналов) и
`GET /healthz` (healthcheck).
- Конфигурация сервиса: `VESTI_BOT_TOKEN` (токен бота-контроллера @dedinit_controller_bot),
`TG_PROXY=socks5://127.0.0.1:1080` (Telegram из РФ доступен только через SOCKS5),
список каналов (сейчас `@dedinit_vesti`, потом несколько) — из .env или отдельного YAML.
- Бот-контроллер: один (id 7765665742, @dedinit_controller_bot), публикует во все каналы,
в которые добавлен администратором.
- `publisher/bot.py` переносится в сервис (логика publish_card/publish_multi/get_views),
но с конфигом каналов вместо шаблона @dedinit_vesti_<dir>_<lang>_bot.
- `web/app.py` больше НЕ импортирует publisher напрямую: вместо publish_multi — HTTP POST
на publisher-service `/api/v1/publish`.
- Dockerfile + docker-compose для сервиса; healthcheck; логирование.
### Не меняется
- Карточка (publisher/card.py) остаётся (формат ≤4096, атрибуция «Дед в АйТи», ссылка на оригинал).
- Режим публикации «черновик на подтверждение» (веб подтверждает → публикация). Без автопостинга.
## Capabilities
### New Capabilities
- `tg-publisher-service`: Изолированный FastAPI-сервис публикации в Telegram:
единая точка `POST /api/v1/publish`, конфиг каналов, прокси SOCKS5 для Bot API,
сбор views, healthcheck.
### Modified Capabilities
- `tg-publisher`: логика публикации переезжает в сервис; вызывающий код (веб) использует HTTP.
- `vesti-web`: approve вызывает publisher-service по HTTP вместо прямого импорта.
- `news-store`: без изменений (бандлы создаёт веб, как раньше).
## Impact
- Затронутые сервисы/порты: publisher-service — новый порт (напр. 8410, локально);
vesti-web :8400 — меняет способ вызова публикатора (HTTP вместо импорта).
- Файлы:
- новый: services/publisher/ (app/, Dockerfile, docker-compose.yml, requirements.py, README.md)
- изменён: web/app.py (HTTP-вызов), .env.example (VESTI_BOT_CHANNELS, TG_PROXY)
- перенос: publisher/bot.py → services/publisher/ (логика сохраняется)
- Данные: без миграций БД (published.tg_message_id/distributed_dirs остаются).
- Секреты: тот же VESTI_BOT_TOKEN (бот-контроллер); TG_PROXY переиспользуется.
- Прокси: Bot API ТОЛЬКО через SOCKS5 127.0.0.1:1080 (api.telegram.org из РФ недоступен).
- Rollback: вернуть в web/app.py импорт publisher.bot (старый путь); сервис можно не запускать.
## Risks
- SOCKS5-прокси недоступен → сервис не может опубликовать: healthcheck должен это показывать.
- Бот не админ канала → sendMessage 403: сервис возвращает понятную ошибку, веб показывает ее.
- Несколько каналов в будущем: конфиг списком (VESTI_BOT_CHANNELS=@a,@b), fan-out по каналам.
- Рестарт сервисов — только извне (SSH sudo systemctl restart) — правило окружения.
@@ -0,0 +1,79 @@
# Spec: tg-publisher-service
## Purpose
Изолированный FastAPI-сервис публикации карточек в Telegram-каналы через Bot API.
Единая точка вызова для всех компонентов (веб, cron, будущие боты). Один бот-контроллер
публикует во все каналы, в которые добавлен администратором. Telegram доступен только
через SOCKS5-прокси (127.0.0.1:1080).
## ADDED Requirements
### Requirement: Изолированный сервис публикации
Сервис MUST быть отдельным FastAPI-приложением (контейнер), НЕ импортируемым модулем веба.
Он MUST предоставлять `GET /healthz` (статус, бот, прокси, каналы) и
`POST /api/v1/publish` (публикация карточки).
#### Scenario: Healthcheck
- **GIVEN** сервис запущен
- **THEN** `GET /healthz` возвращает 200 с `{"status":"ok","bot":...,"channels":[...]}`
и healthcheck в docker-compose (`curl -f`) проходит
#### Scenario: Публикация карточки
- **GIVEN** POST /api/v1/publish c `card: {text, direction, lang}` и `channels: ["@dedinit_vesti"]`
- **THEN** сервис отправляет сообщение через Bot API в каждый канал и возвращает
`{"ok":true,"results":{"@dedinit_vesti":{"message_id":<int>,"views":0}}}`
### Requirement: Один бот, несколько каналов (конфиг)
Сервис MUST поддерживать список каналов из конфигурации (`VESTI_BOT_CHANNELS`, через запятую).
`channels` в запросе MAY переопределять список. Если бот добавлен в канал администратором —
публикация MUST работать; иначе сервис MUST вернуть понятную ошибку (403).
#### Scenario: Несколько каналов
- **GIVEN** `VESTI_BOT_CHANNELS=@dedinit_vesti,@other_channel` (оба добавлены боту)
- **WHEN** POST /api/v1/publish без поля channels
- **THEN** карточка публикуется в оба канала; results содержит оба message_id
#### Scenario: Бот не админ канала
- **GIVEN** канал, в котором бот не администратор
- **WHEN** публикация в него
- **THEN** сервис возвращает 403 с текстом ошибки Bot API (sendMessage → Forbidden)
### Requirement: Прокси SOCKS5 для Bot API
Все запросы к api.telegram.org MUST идти через прокси из `TG_PROXY=socks5://127.0.0.1:1080`.
Если прокси недоступен — сервис MUST вернуть 502 (не падать).
#### Scenario: Прокси недоступен
- **GIVEN** прокси 127.0.0.1:1080 выключен
- **WHEN** POST /api/v1/publish
- **THEN** сервис возвращает 502 с ошибкой подключения (не 500, не краш)
### Requirement: Сбор views
Сервис MUST предоставлять способ получения просмотров для опубликованных сообщений
(через Bot API getMessage), чтобы веб показывал метрики.
#### Scenario: Views после публикации
- **GIVEN** сообщение опубликовано (message_id получен)
- **WHEN** запрос views для этого message_id
- **THEN** возвращается число просмотров (0 если ещё нет)
### Requirement: Режим подтверждения
Сервис MUST НЕ публиковать автоматически: вызывается только по HTTP-запросу (из веба
после клика «Опубликовать»). Автопостинга по таймеру внутри сервиса НЕТ.
#### Scenario: Нет автопостинга
- **GIVEN** сервис запущен без входящих запросов
- **THEN** ничего не публикуется (процесс только слушает HTTP)
## Modified Requirements (из tg-publisher)
- `publish_multi` и `get_views` переезжают в сервис (HTTP-интерфейс вместо импорта).
- vesti-web: approve вызывает POST /api/v1/publish; ответ используется для
distributed_dirs + views (как раньше, только источник данных — HTTP).
## NOT Requirements
- Не реализуем чтение каналов (краулинг) — это остаётся в tg-crawler (Telethon).
- Не реализуем веб-интерфейс сервиса (только API + healthz).
- Не храним БД в сервисе (вся персистентность — в vesti.db через веб).
- Не делаем автопостинг (см. Requirement выше).
@@ -0,0 +1,51 @@
# Tasks: publisher-service
## 1. Скелет сервиса
- [x] 1.1 Создать services/publisher/ (app/, requirements.txt, .env.example, README.md)
Проверка: `ls services/publisher/app/` → main.py, config.py, telegram.py, channels.py
- [x] 1.2 requirements.txt: fastapi, uvicorn, httpx[socks], pydantic, python-dotenv
Проверка: `.venv/bin/pip install -r services/publisher/requirements.txt` без ошибок
- [x] 1.3 config.py: env VESTI_BOT_TOKEN, TG_PROXY, VESTI_BOT_CHANNELS, PUBLISHER_PORT
Проверка: `python -c "from services.publisher.app.config import Settings; print(Settings().channels)"` → ['@dedinit_vesti']
- Замечание: токен не читался из-за порядка (os.getenv при ClassVar, до load_dotenv) и неверного пути BASE (3x parent → /opt/vesti/services, нужно parents[3] → /opt/vesti). Исправлено: Settings → @dataclass + __post_init__, load_dotenv(override=True), BASE=parents[3].
## 2. Telegram-клиент (Bot API через SOCKS5)
- [x] 2.1 telegram.py: клиент httpx с proxy=socks5://127.0.0.1:1080, методы sendMessage/sendPhoto/getMessage
- [x] 2.2 channels.py: разбор VESTI_BOT_CHANNELS ("@a,@b" → ["@a","@b"])
- [x] 2.3 Обработка ошибок: 403 (бот не админ), сеть (прокси) → 502
## 3. FastAPI-эндпоинты
- [x] 3.1 main.py: GET /healthz (status, bot, proxy, channels)
Проверка: `curl -s :8410/healthz` → ok + бот + каналы. Реальное: {"status":"ok","bot":"dedinit_controller_bot","token_set":true,"proxy":"socks5://127.0.0.1:1080","channels":["@dedinit_vesti"]}
- [x] 3.2 main.py: POST /api/v1/publish (card {text, media?, direction, lang}, channels?) → results по каналам
Проверка: `curl -X POST :8410/api/v1/publish -d '{"card":{"text":"тест","direction":"linux","lang":"ru"}}'` → ok, message_id в @dedinit_vesti. Реальное: {"ok":true,"results":{"@dedinit_vesti":{"message_id":2,...}}}
- [x] 3.3 views: эндпоинт GET /api/v1/views/{channel}/{mid} (в main.py) + поле views в ответе publish (get_views при публикации)
## 4. Контейнеризация
- [x] 4.1 Dockerfile: python:slim, non-root, read-only fs, expose 8410
- [x] 4.2 docker-compose.yml: сервис publisher, порт 127.0.0.1:8410, env из .env, healthcheck curl /healthz
- Проверено 2026-09-09: `docker compose up -d --build` → vesti-publisher Up (healthy), 127.0.0.1:8410, healthz: bot=dedinit_controller_bot, proxy=host.docker.internal.
- Питфолы Docker: (1) пути в compose отсчитываются от services/publisher/ → .env надо `../../.env`, media `../../media`; (2) TG_PROXY=127.0.0.1 в контейнере = сам контейнер → заменить на socks5://host.docker.internal:1080 + extra_hosts host-gateway; (3) load_dotenv(override=True) перебивал env контейнера → override=False (env окружения приоритетнее .env); (4) dataclass-дефолт tg_proxy="socks5://127.0.0.1:1080" был truthy → os.getenv не срабатывал → дефолт сделан пустым.
## 5. Интеграция с вебом
- [x] 5.1 web/app.py: убрать `from publisher.bot import publish_multi/get_views_multi`; вместо них web/publisher_client.py (HTTP POST на PUBLISHER_URL, default http://127.0.0.1:8410)
- Проверка: grep — импорт publisher.bot убран; карточка шлёт card{direction,lang}, сервис берёт каналы из VESTI_BOT_CHANNELS.
- [x] 5.2 Обновить .env.example (VESTI_BOT_CHANNELS, TG_PROXY, PUBLISHER_URL)
- [x] 5.3 Реальный approve через веб: сквозной путь веб → HTTP → publisher(Docker) → канал.
- Проверено 2026-09-09: approve постов 136 и 137 через POST /posts/<id>/approve — 302 → /published, статус published, бандл, tg_message_id=3 и 4 в @dedinit_vesti.
- Веб-баги, найденные при проверке: (1) `raise RedirectResponse(...)` в _require_auth → TypeError (исключение не BaseException) → 500 на /candidates; фикс: HTTPException(303, headers={"Location": "/login"}); (2) tg_message_id для внешних постов искался по направлению (results["linux"]), а ключи results — каналы (@dedinit_vesti) → 0; фикс: брать первый message_id из results.values().
## 6. Проверка и документация
- [x] 6.1 `openspec validate publisher-service` → 0 ошибок (valid)
- [x] 6.2 Реальная публикация тестовой карточки в @dedinit_vesti (бот админ) → message_id в ответе. Реальное: message_id=2, ok.
- Бот добавлен админом канала (подтверждено пользователем).
- [x] 6.3 Обновить STATUS.md / TODO.md — сделано; WALKTHROUGH/PRD — обновлено (см. PRD.md)
## Открытые пункты
- [ ] publisher: медиа из card.media — путь в БД /opt/vesti/media/... не совпадает с монтированием в контейнере (/srv/publisher/media) → send_photo не уходит при Docker-запуске (нужен маппинг путей или передача имени файла)
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-12
@@ -0,0 +1,69 @@
## Дизайн
### 1. Формат карточки (publisher/card.py)
`make_card(post, comment=None)` теперь собирает текст репоста:
```
[комментарий модератора (если задан)]
[пустая строка]
[полный текст исходного поста]
[пустая строка]
🔗 Оригинал: <url> (или атрибуция «Дед в АйТи (@dedinit) · t.me/dedinit/<id>» для своих)
```
- Полный текст поста (post["text"]) вместо сниппета/summary; обрезка до Telegram-лимита
(MAX_TEXT=4096) с учётом ссылки и комментария.
- Служебная информация (📁 направление/язык, 📰 источник, 👁 просмотры) убирается из тела
поста — это был «агрегаторный» вид. Просмотры/реакции остаются только в бандле и вебе.
- Ссылка на оригинал — всегда последней строкой (для внешних: `🔗 Оригинал: <url>`;
для своих: атрибуция + ссылка `t.me/dedinit/<id>` как раньше).
- HTML-экранирование текста поста и комментария (вход — непроверенный текст, правило XSS).
### 2. Медиа (publisher/card.py + services/publisher)
- Медиа отправляется ПЕРВЫМ сообщением (фото или видео), затем — текст.
- Разрешить video: `card.py` фильтр `.mp4` больше не отбрасывает (content_type=video).
- `services/publisher/app/telegram.py` — новый `send_video(chat_id, path, caption="")`
(Bot API sendVideo), общий `send_media` по расширению.
- Caption у медиа: короткий (`<b>⬇️ Пост ниже</b>` или комментарий), НЕ дублировать весь
текст (раньше photo caption обрезался до 1024 и текст всё равно слался вторым).
### 3. Отключение предпросмотра ссылки (services/publisher/app/telegram.py)
`send_message` → добавить параметр:
```python
link_preview_options={"is_disabled": True}
```
чтобы Telegram НЕ добавлял предпросмотр оригинальной ссылки в конце поста.
### 4. Веб: поле «Комментарий» (web/app.py + templates/candidates.html)
- В форме approve (для всех постов — и свои, и внешние) текстовое поле
`<textarea name="comment" ...>Комментарий (по желанию)</textarea>`.
- `approve`: `comment = (await request.form()).get("comment", "").strip()` → в card.
- Комментарий уходит в published? Нет (не меняем схему БД) — только в текст поста и в
метаданные бандла? Для простоты: комментарий включается в текст карточки (и, если
хочется, в frontmatter бандла `comment:` — опционально, без схемы БД).
### 5. Полный текст в бандле
- Бандл (web/store.py) УЖЕ хранит полный текст поста — не меняется.
### 6. Обратная совместимость
- `make_card(post)` без comment работает (comment=None).
- Старый `send_photo`/`send_message` остаются; main.py выбирает: если media → send_media,
потом send_message (link_preview disabled).
- Если текст пустой (пост только медиа) — текст-сообщение можно пропустить? Нет:
ссылка на оригинал должна быть → текст генерируется всегда (минимум ссылка).
### 7. Проверка
- Юнит: `make_card` с comment → комментарий первым, полный текст, ссылка, без служебки.
- Карточка ≤4096 (обрезается).
- sendVideo работает для реального mp4 (в Docker медиа-маунт уже есть).
- В канале: медиа + текст + ссылка, предпросмотра ссылки нет.
- Веб: поле комментария видно, approve с комментарием → в канале комментарий первым.
@@ -0,0 +1,50 @@
## Why
Пользователь опубликовал несколько постов через веб-approve в канал @dedinit_vesti
и увидел, что в канале пост выглядит «как простой агрегатор»:
- только ссылка на чужой канал (заголовок-сниппет из первых 120 символов текста),
- служебная информация по направлениям (📁 направление/язык, 📰 источник, 👁 просмотры),
- в конце Telegram добавляет предпросмотр ссылки (link preview).
Такой репост никому не интересен: не видно ни медиа из оригинального поста, ни
полного текста, ни комментария модератора — только «голая» ссылка и служебка.
## What Changes
Формат публикуемого поста (карточка) меняется на «богатый репост»:
1. **Медиа из оригинального поста** — если у поста есть media_path (фото/видео),
оно отправляется **первым сообщением** (sendPhoto/sendVideo), а текст — следующим.
Сейчас sendPhoto есть, но caption обрезается до 1024 и текст дублируется вторым
сообщением; видео (content_type=video) вообще не отправляется как медиа (фильтр в card.py).
2. **Полный текст поста** — вместо сниппета из первых 120 символов (make_card) или
summary отправляется полный текст исходного поста (обрезанный до Telegram-лимита 4096).
3. **Комментарий модератора** — в веб-форме approve добавляется поле «Комментарий
(по желанию)»; комментарий публикуется первым (или в начале текста), чтобы в канале
было видно мнение/контекст редактора, а не только пересказ.
4. **Ссылка на оригинал БЕЗ предпросмотра** — ссылка на исходный пост остаётся в тексте,
но Telegram-предпросмотр ссылки отключается (sendMessage параметр
`link_preview_options={"is_disabled": True}`), чтобы канал не выглядел как агрегатор
ссылок с превью.
5. **Служебная информация** — частично убирается/переформатируется: направление/язык,
источник, просмотры больше НЕ обязательны в теле поста (внешняя ссылка на оригинал
уже даёт контекст). Остаётся атрибуция для своего контента («Дед в АйТи»).
## Impact
- `publisher/card.py` — построение текста карточки (полный текст + комментарий + ссылка
+ атрибуция, служебка по минимуму).
- `services/publisher/app/telegram.py` — поддержка видео (sendVideo), параметр
`link_preview_options` для sendMessage; send_photo с полным caption без обрезания
(или без дублирования текста).
- `services/publisher/app/main.py` — Card модель: +`comment`; передача link_preview_options.
- `web/app.py` (approve) — чтение комментария из формы, проброс в card.
- `web/templates/candidates.html` — поле ввода комментария в форме approve.
- `publisher/card.py` — фильтр медиа: разрешить video (mp4), не только картинки.
- Rollback: вернуть прежний make_card и вызовы; карточки станут как раньше.
- Безопасность: комментарий — пользовательский ввод → HTML-экранирование как текст поста.
@@ -0,0 +1,120 @@
# TG Publisher — формат «богатого репоста»
## ADDED Requirements
### Requirement: Карточка репоста — полный текст
При публикации внешнего поста карточка MUST содержать полный текст
исходного поста (а не сниппет из первых 120 символов), обрезанный до лимита Telegram
(4096 символов).
#### Scenario: публикация поста с длинным текстом
- Given пост с текстом длиной 3000 символов и ссылкой на оригинал
- When модератор подтверждает пост в вебе
- Then в канале отправляется текст с полным содержимым поста (обрезанный до 4096)
- And заголовок-сниппет из 120 символов НЕ используется
### Requirement: Карточка репоста — комментарий модератора
При подтверждении поста модератор МОЖЕТ указать комментарий; комментарий
MUST публиковаться первым блоком в тексте поста.
#### Scenario: approve с комментарием
- Given пост, подтверждаемый через веб
- When модератор вводит комментарий «Отличный материал по Linux» и нажимает «Опубликовать»
- Then текст карточки начинается с комментария «Отличный материал по Linux»
- And полный текст исходного поста следует после комментария
#### Scenario: approve без комментария
- Given пост, подтверждаемый через веб
- When модератор оставляет поле комментария пустым
- Then текст карточки начинается с полного текста исходного поста
- And лишних пустых блоков нет
### Requirement: Карточка репоста — ссылка на оригинал без предпросмотра
Ссылка на оригинальный пост MUST присутствовать в тексте карточки, и
Telegram-предпросмотр этой ссылки MUST быть отключён (link_preview_options is_disabled).
#### Scenario: публикация внешнего поста
- Given внешний пост с url на оригинал
- When карточка отправляется в канал
- Then в тексте есть строка со ссылкой на оригинал
- And Telegram НЕ добавляет предпросмотр ссылки внизу поста
### Requirement: Карточка репоста — медиа из оригинала
Если у исходного поста есть медиа (photo/video), публикация MUST
отправлять медиа первым сообщением, а текст — следующим; видео (.mp4) MUST
поддерживаться.
#### Scenario: пост с фото
- Given пост с media_path=media/xyz.jpg
- When модератор подтверждает пост
- Then в канал отправляется фото (sendPhoto) первым сообщением
- And текст карточки отправляется следующим сообщением
#### Scenario: пост с видео
- Given пост с content_type=video и media_path=media/xyz.mp4
- When модератор подтверждает пост
- Then в канал отправляется видео (sendVideo) первым сообщением
- And текст карточки отправляется следующим сообщением
### Requirement: Карточка репоста — без служебной информации
Служебная информация (направление/язык, источник, просмотры/реакции)
MAY НЕ выводиться в теле поста; основное содержимое — текст, комментарий, ссылка на
оригинал.
#### Scenario: пост без служебки
- Given внешний пост с направлением и просмотрами
- When карточка формируется
- Then в тексте карточки НЕТ строк «📁 linux / ru», «📰 источник», «👁 123 💬 4»
- And текст содержит полный текст поста и ссылку на оригинал
## MODIFIED Requirements
### Requirement: Формат карточки (publisher/card.py)
Функция make_card MUST принимать необязательный параметр `comment` и
строить текст из: комментария (если задан), полного текста поста, ссылки на оригинал
(атрибуция для своих).
#### Scenario: make_card с comment
- Given post с текстом «Важная новость» и comment «Мой комментарий»
- When вызывается make_card(post, comment)
- Then результат содержит «Мой комментарий» перед «Важная новость»
- And ссылка на оригинал присутствует
#### Scenario: make_card без comment
- Given post с текстом «Важная новость» и comment=None
- When вызывается make_card(post)
- Then результат НЕ содержит пустого первого блока
- And результат содержит полный текст «Важная новость» и ссылку на оригинал
### Requirement: Отправка медиа в publisher-service
Сервис публикации MUST отправлять медиа первым и текст вторым; для
sendMessage параметр link_preview_options MUST отключать предпросмотр ссылки.
#### Scenario: sendMessage без предпросмотра
- Given канал @dedinit_vesti и текст со ссылкой на оригинал
- When вызывается sendMessage
- Then параметр link_preview_options={"is_disabled": True} передаётся в Bot API
#### Scenario: sendVideo для mp4
- Given файл media/xyz.mp4 существует в медиа-маунте
- When публикуется пост с этим видео
- Then вызывается sendVideo (а не sendPhoto)
- And текст отправляется следом
@@ -0,0 +1,24 @@
## 1. Карточка (publisher/card.py)
- [ ] 1.1 `make_card(post, comment=None)`: полный текст поста вместо сниппета; служебка (📁/📰/👁) убрана; ссылка на оригинал последней строкой; comment первым
- [ ] 1.2 HTML-экранирование comment (XSS-safe как текст)
- [ ] 1.3 Медиа: разрешить video (.mp4), фильтр не отбрасывает
- [ ] 1.4 Проверка: карточка ≤4096, эмодзи/ссылки на месте
## 2. Publisher-сервис (services/publisher/)
- [ ] 2.1 `telegram.py`: `send_media` (photo/video по расширению) + `send_video`; sendMessage с `link_preview_options={"is_disabled": True}`
- [ ] 2.2 `main.py`: Card + `comment`; если media → send_media первым, затем send_message
- [ ] 2.3 healthz/publish не ломаются; Docker пересобран (медиа-маунт уже есть)
## 3. Веб (web/)
- [ ] 3.1 `candidates.html`: поле «Комментарий (по желанию)» в форме approve (все посты)
- [ ] 3.2 `app.py` approve: читать `comment` из формы, передать в card
- [ ] 3.3 Проверка: approve с комментарием → в канале комментарий первым, текст полный, ссылка без превью
## 4. Документация и регресс
- [ ] 4.1 requirements без изменений (media/video — Bot API)
- [ ] 4.2 STATUS.md / PRD.md / TODO.md обновлены
- [ ] 4.3 Регресс: старые посты (текст) публикуются как раньше, тест в канал реальный (1 пост)
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-08
@@ -0,0 +1,105 @@
# Design: tg-crawler-publisher-prototype
## Approach
Прототип = вертикаль «TG-источники → классификация → подтверждение → публикация → банк статей» на направлении Линукс (ru). Горизонталь (n8n, Postgres, S3, VK/fediverse-боты, веб-на-домене) — фаза 2+.
Поток данных:
```
sources.yaml (реестр, git)
│
▼
telegram_crawler.py (cron: каждые 15 мин, Telethon через SOCKS5 :1080)
│ инкрементально после last_post_id, метрики views/reactions
│ форварды: fwd_from → донор; свой источник → пропуск; чужой → fwd-поля без медиа
│ медиа: content_type + скачивание media/{channel}_{post_id}.{ext}, без повторных загрузок
▼
SQLite /opt/vesti/db/vesti.db (raw-посты, состояния каналов)
│
▼
classifier.py (локальный qwen3:8b-nothink + словарный фильтр)
│ направление: linux, релевантность, summary, интересность 1-5
▼
SQLite (статьи-кандидаты: candidate/new/approved/published/rejected)
│
▼
vesti-web (FastAPI + Bootstrap 5.3 + Jinja2 + HTMX, 127.0.0.1:8400)
│ админ-панель: кандидаты → подтвердить/отклонить, метрики
▼ (подтверждённые)
tg-publisher.py (Bot API: карточка-пост в канал @dedinit_vesti_linux_ru_bot)
▼
bundles/linux/2026-09/<slug>.md (markdown + frontmatter) + media/
```
## Files
```
/opt/vesti/
├── docker-compose.yml # (фаза 2; прототип — systemd/python)
├── .env # секреты (не в git): токен бота, api_hash
├── requirements.txt # telethon, httpx, feedparser, fastapi, uvicorn, jinja2, python-dotenv, dateutil
├── sources/sources.yaml # реестр источников (продолжение формата /opt/news)
├── crawler/
│ ├── __init__.py
│ ├── telegram_crawler.py # Telethon-краулер, инкрементальный, форварды, медиа
│ └── state.py # last_post_id на канал (SQLite)
├── classifier/
│ ├── __init__.py
│ ├── keywords.py # словари по направлениям (linux-слова теперь)
│ └── classify.py # qwen3:8b-nothink JSON: direction, relevance, interest, summary
├── publisher/
│ ├── __init__.py
│ ├── bot.py # Bot API публикация (карточка)
│ └── card.py # форматирование карточки в лимит поста
├── web/
│ ├── __init__.py
│ ├── app.py # FastAPI
│ ├── auth.py # простая сессия/пароль (одна учётка)
│ ├── templates/ # Jinja2: base, index, candidates, metrics, login
│ └── static/ # bootstrap 5.3 (CDN fallback локально)
├── bundles/ # банк статей (git-репо vesti-bundles)
│ └── linux/2026-09/<slug>.md
├── media/ # скачанные медиа-файлы постов (media/{channel}_{post_id}.{ext})
├── db/ # vesti.db (не в git)
└── scripts/
└── run_all.sh # запуск краулера+классификатора+публикатора
```
## Commands
```bash
# Установка зависимостей
cd /opt/vesti && python3.12 -m venv .venv && .venv/bin/pip install -r requirements.txt
# Краулер (ручной прогон)
.venv/bin/python -m crawler.telegram_crawler --channels linux
# Классификатор
.venv/bin/python -m classifier.classify --direction linux
# Веб (локально)
cd /opt/vesti && .venv/bin/uvicorn web.app:app --host 127.0.0.1 --port 8400
# Проверка: живой ли веб
curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8400/login
```
## Rollback
```bash
# Остановить сервисы
sudo systemctl stop vesti-crawler vesti-publisher vesti-web # (или kill процессов)
# Веб больше не слушает
curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8400/ # → 000
# Удалить каталог прототипа (только по явному согласованию с пользователем)
rm -rf /opt/vesti
```
Существующий /opt/news не затрагивается.
## Risks
- **Авторизация Telegram-сессии**: Telethon-сессия /opt/news/telegram/ переиспользуется; если она протухла/забанена — нужна разовая интерактивная авторизация (код) — потребует участия пользователя.
- **Flood-wait/429 в TG**: Telethon обходит автоматически; лимит ~20 постов/канал/запуск.
- **qwen3:8b требует `extra_body={"think": false}`** (известный quirk) — иначе пустой content.
- **Скриншоты/текст исходников**: в прототипе сохраняем текст + ссылку (полный HTML/скриншоты — фаза 2).
- Секреты: токен бота и api_hash только в .env, не коммитить.
@@ -0,0 +1,35 @@
## Why
VESTI — новостной агрегатор с веб-интерфейсом (транслитерация «вести»). Эволюция /opt/news: от «базы ИТ/hospitality новостей» к полноценному медиа-конвейеру: краулинг из многих источников → дедуп и слияние в одну статью → факт-чек → категоризация → банк markdown-статей → дайджесты для еженедельных видео + ленты ботов (Telegram, VK, fediverse).
Первый рабочий прототип — вертикаль: **Telegram-краулер публичных каналов + бот-публикатор в новостной канал + минимальный веб**. Это даёт рабочий конвейер «источник → отбор → публикация» на одном направлении (Линукс, русский язык) быстрее всего.
## What Changes
- Создать сервис-краулер: Telethon (MTProto) читает публичные Telegram-каналы из реестра источников, инкрементально (после `last_post_id`), метрики постов (views/reactions) сохраняются.
- Создать классификатор: локальный LLM (Ollama qwen3:8b-nothink) относит пост к направлению (Технологии/Политика/Игры/Электроника/БЯМ/Линукс) и оценивает релевантность/интересность.
- Создать бот-публикатор: Telegram Bot API, режим «черновик на подтверждение» (C2-b) — человек подтверждает пост в вебе/личке до публикации в канале; формат поста — карточка в лимит одного поста с картинкой в начале (C3).
- Создать минимальный веб-интерфейс: FastAPI + Bootstrap 5.3 + Jinja2 + HTMX, авторизация (один админ), список внешних новостей с метриками, список опубликованных, черновики на подтверждение, базовая метрика интереса.
- Первые направления: Линукс, язык ru. Канал-источник: прототип на t.me/linuxklub и других линукс-каналах из списка пользователя.
- n8n: НЕ входит в прототип (только RSS/email/сайты на фазе 2). Веб — только локально. WeChat/X — фаза 2+.
## Capabilities
### New Capabilities
- `tg-crawler`: Чтение публичных Telegram-каналов через Telethon (MTProto) через SOCKS5-туннель, инкрементальный обход, сбор метрик постов.
- `classifier`: Классификация постов по направлениям (Технологии/Политика/Игры/Электроника/БЯМ/Линукс) локальным LLM + словарный быстрый фильтр.
- `tg-publisher`: Telegram-бот-публикатор с режимом «черновик на подтверждение», формат карточки поста.
- `vesti-web`: Веб-интерфейс управления: списки внешних/своих новостей, метрики, подтверждение черновиков, авторизация.
- `news-store`: Банк статей: markdown-бандлы с frontmatter (заголовок, направление, источники, метрики) + media/ рядом; SQLite для прототипа (Postgres — следующий шаг по решению D1).
### Modified Capabilities
- (нет)
## Impact
- Новые каталоги/сервисы: /opt/vesti/crawler, /opt/vesti/classifier, /opt/vesti/publisher, /opt/vesti/web, /opt/vesti/bundles.
- Зависимости: Telethon (уже в /opt/news), Ollama qwen3:8b-nothink (:11434), python-telegram-bot (Bot API), FastAPI, Bootstrap 5.3 (CDN), SQLite.
- Секреты: api_id/api_hash (24276216, из /opt/icq/docker-compose.yml), Telethon-сессия (переиспользуем /opt/news/telegram/), токен бота — в .env.
- Порт: веб 127.0.0.1:8400 (локально).
- Git: новые репозитории (источник истины gitverse.ru, зеркало gitea bigbox:3000).
- Rollback: прототип не трогает существующий /opt/news; удаление — остановка systemd-юнитов/контейнеров и удаление каталогов /opt/vesti (по согласованию).
@@ -0,0 +1,33 @@
## Purpose
Классификация собранных постов по направлениям (Технологии, Политика, Игры, Электроника, БЯМ, Линукс) с оценкой релевантности и интереса.
## ADDED Requirements
### Requirement: Быстрый словарный фильтр по направлениям
Система MUST сначала прогонять пост через словарный фильтр (keywords.py): совпадение по словам направления (например, linux, kernel, distro, gnome, kde для Линукс) → кандидат на направление.
#### Scenario: Пост про Linux
- **WHEN** пост содержит слова «kernel», «gnome», «apt» и т.п.
- **THEN** пост помечается кандидатом на направление Линукс без вызова LLM
### Requirement: LLM-классификация локальным qwen3:8b-nothink
Система MUST для кандидатов вызывать Ollama qwen3:8b-nothink (extra_body={"think": false}) с запросом JSON: direction, relevance (critical/high/low), interest (1-5), summary. Если модель недоступна — пост остаётся unclassified.
#### Scenario: LLM-классификация поста
- **WHEN** пост прошёл словарный фильтр и вызывает classifier.classify()
- **THEN** возвращается JSON с direction/relevance/interest/summary, запись помечается classified
### Requirement: Релевантность определяет обработку
Посты с relevance=critical/high MUST появляться в веб-кандидатах для подтверждения; low — оставаться в БД, но не показываться как кандидаты по умолчанию.
#### Scenario: Низкая релевантность
- **WHEN** классификатор ставит relevance=low
- **THEN** пост виден в вебе только при явном фильтре «все», в кандидатах по умолчанию отсутствует
### Requirement: Оценка интереса 1-5
Классификатор SHOULD выставлять interest (1-5) на основе остроты темы и вовлечённости (реакции/просмотры поста).
#### Scenario: Хайповый пост
- **WHEN** пост имеет много просмотров и реакций
- **THEN** классификатор учитывает это и может поднять interest
@@ -0,0 +1,33 @@
## Purpose
Банк статей VESTI: markdown-файлы с frontmatter (метаданными) и мультимедиа в каталогах-бандлах.
## ADDED Requirements
### Requirement: Markdown-бандлы с frontmatter
Каждая статья MUST храниться как markdown-файл с YAML frontmatter: title, date, direction (направление), source (источник), url, views, reactions, interest, status. Путь: bundles/<направление>/<YYYY-MM>/<slug>.md.
#### Scenario: Создание статьи в банке
- **WHEN** пост подтверждён и опубликован
- **THEN** в bundles/linux/2026-09/<slug>.md создаётся файл с frontmatter и телом (summary + ссылки на источники)
### Requirement: Медиа рядом со статьёй
Медиа-файлы статьи MUST храниться в подкаталоге media/ рядом с markdown (<bundles>/<direction>/<YYYY-MM>/media/<article-slug>/). Ссылки в frontmatter указывают относительные пути.
#### Scenario: Статья с картинкой
- **WHEN** пост имеет картинку
- **THEN** картинка сохраняется в media/<slug>/ и упоминается в frontmatter
### Requirement: Ссылки на все источники
Статья MUST содержать раздел «Источники» с ссылками на все связанные посты/страницы (для атрибуции и защиты от претензий).
#### Scenario: Несколько источников
- **WHEN** новость собрана из 2+ источников
- **THEN** каждый источник указан в разделе «Источники» с URL
### Requirement: Версионирование банка в git
Банк статей MUST храниться в отдельном git-репозитории (vesti-bundles) для истории и бэкапов (источник истины gitverse.ru, зеркало gitea).
#### Scenario: Коммит после публикации
- **WHEN** создаётся новая статья
- **THEN** репозиторий банка получает коммит (или ставится в очередь авто-коммита)
@@ -0,0 +1,66 @@
## Purpose
Чтение публичных Telegram-каналов через Telethon (MTProto) для сбора новостей с метриками популярности.
## ADDED Requirements
### Requirement: Чтение публичных каналов через SOCKS5-туннель
Система MUST читать публичные Telegram-каналы через Telethon, подключаясь ТОЛЬКО через SOCKS5 127.0.0.1:1080 (telegram-tunnel → VPS01). Прямое подключение из РФ заблокировано.
#### Scenario: Краулер подключается к каналу
- **WHEN** запускается telegram_crawler.py для канала из sources.yaml
- **THEN** соединение идёт через socks5://127.0.0.1:1080, и посты канала читаются
### Requirement: Инкрементальный обход каналов
Краулер MUST хранить last_post_id/last_ts на канал (таблица tg_state) и при следующем запуске читать только посты после последнего.
#### Scenario: Повторный запуск
- **WHEN** краулер запускается повторно для того же канала
- **THEN** запрашиваются только посты с id > last_post_id; дубли в БД не появляются
### Requirement: Сбор метрик постов
Краулер MUST сохранять для каждого поста: text, ссылки, views (просмотры), реакции (реакции), дату, канал. Метрики хранятся в SQLite vesti.db.
#### Scenario: Пост с реакциями
- **WHEN** пост канала имеет просмотры и реакции
- **THEN** views и reactions сохраняются в записи поста и доступны веб-интерфейсу
### Requirement: Устойчивость к rate-limit
Краулер MUST соблюдать лимиты Telegram (flood-wait, 429): не более ~20 постов/канал/запуск, автоматическая пауза при 429 (встроено в Telethon), повторный запуск не ломает состояние.
#### Scenario: Telegram отвечает 429
- **WHEN** Telegram отдаёт flood-wait при чтении канала
- **THEN** краулер ждёт необходимое время и продолжает; ошибка не теряет last_post_id
### Requirement: Обработка форвардов (пересланных постов)
Краулер MUST анализировать поле `fwd_from` у каждого поста. Если пост переслан из канала (PeerChannel), определять канал-донор (channel_id) и номер исходного поста (channel_post).
Политика:
- **Донор есть в списке источников** — пост MUST НЕ сохраняться (дубль исходного; исходный придёт от своего источника).
- **Донора нет в источниках** — пост MAY сохраняться как «упоминание» с полями `fwd_from_channel_id` / `fwd_from_post_id`; медиа для такого поста MUST НЕ скачиваться (контент принадлежит донору).
#### Scenario: Канал пересылает из источника
- **WHEN** канал B (не источник) пересылает пост канала A (источник)
- **THEN** пост НЕ сохраняется в БД; дубликат исходного не создаётся
#### Scenario: Канал пересылает из не-источника
- **WHEN** канал B пересылает пост канала C (не в списке источников)
- **THEN** пост сохраняется с fwd_from_channel_id/fwd_from_post_id, media_path=NULL (медиа не скачивается)
### Requirement: Дедупликация контента
Краулер MUST не создавать дубликаты: (1) один и тот же пост канала (url) не сохраняется дважды; (2) посты с одинаковым текстом (sha256) не сохраняются дважды.
#### Scenario: Повторный краулинг того же поста
- **WHEN** пост с тем же url/text уже есть в БД
- **THEN** новый экземпляр не создаётся; существующий может дополняться метаданными (content_type, media_path, fwd-поля), но не дублироваться
### Requirement: Типы контента и медиа
Краулер MUST определять тип контента поста (text/photo/video/document/voice/sticker) и для медиа-постов скачивать файл в `media/` (путь `media/{channel}_{post_id}.{ext}`), сохраняя `content_type` и `media_path` в БД. Медиа НЕ должно скачиваться повторно: если файл уже есть на диске, повторное скачивание MUST быть пропущено.
#### Scenario: Пост с фото
- **WHEN** пост содержит фото
- **THEN** content_type='photo', media_path='media/{channel}_{post_id}.jpg', файл существует
#### Scenario: Повторный запуск с уже скачанным медиа
- **WHEN** медиа-файл уже существует в media/
- **THEN** файл не скачивается заново (экономия трафика/лимитов)
@@ -0,0 +1,33 @@
## Purpose
Публикация отобранных новостей в Telegram-канал через Bot API (бот @dedinit_vesti_<направление>_<язык>_bot), с режимом подтверждения человеком.
## ADDED Requirements
### Requirement: Режим «черновик на подтверждение»
Система MUST NOT публиковать посты автоматически: каждый пост сначала становится черновиком в вебе, публикуется ТОЛЬКО после явного подтверждения человеком.
#### Scenario: Новый кандидат
- **WHEN** кандидат проходит классификацию с relevance=critical/high
- **THEN** он появляется в вебе как «черновик на подтверждение» и НЕ публикуется в канал до клика «Опубликовать»
### Requirement: Формат поста — карточка в лимит одного поста
Каждый опубликованный пост MUST укладываться в лимит Telegram-сообщения: картинка/медиа в начале (если есть), затем заголовок, краткое описание (summary), направление, ссылка на источник. Длина текста ≤ 4096 символов.
#### Scenario: Пост с картинкой
- **WHEN** у поста есть медиа (картинка)
- **THEN** картинка отправляется как фото (первым сообщением/вложением), затем текст-карточка единым сообщением
### Requirement: Идентификация канала по направлению/языку
Имя бота/канала формируется по шаблону @dedinit_vesti_<направление>_<язык>_bot (например @dedinit_vesti_linux_ru_bot). Параметры направления/языка берутся из конфигурации (.env/config).
#### Scenario: Публикация в правильный канал
- **WHEN** подтверждается пост направления linux, язык ru
- **THEN** он публикуется в канал, соответствующий @dedinit_vesti_linux_ru_bot
### Requirement: Метрики своих постов
Бот/веб MUST сохранять для опубликованных постов views (просмотры) через Bot API, чтобы вести метрики популярности своих публикаций.
#### Scenario: Просмотры своего поста
- **WHEN** опубликованный пост получает просмотры
- **THEN** веб периодически опрашивает Bot API и сохраняет views для аналитики
@@ -0,0 +1,40 @@
## Purpose
Веб-интерфейс управления VESTI: авторизация, списки кандидатов и опубликованных новостей, метрики, подтверждение черновиков.
## ADDED Requirements
### Requirement: Локальный запуск с авторизацией
Веб MUST запускаться на 127.0.0.1:8400 (только локально, без внешнего домена) и требовать авторизацию (одна учётная запись админа, пароль в .env).
#### Scenario: Доступ без авторизации
- **WHEN** неавторизованный пользователь открывает /
- **THEN** он перенаправляется на /login
### Requirement: Список внешних новостей с метриками
Веб MUST показывать список внешних постов (источник, заголовок, направление, views/reactions, дата) с фильтрами по направлению и статусу.
#### Scenario: Фильтр по направлению
- **WHEN** админ выбирает направление «Линукс»
- **THEN** отображаются только посты этого направления
### Requirement: Подтверждение черновиков
Веб MUST позволять админу подтверждать («Опубликовать») или отклонять черновики; подтверждение запускает публикацию через tg-publisher.
#### Scenario: Подтверждение поста
- **WHEN** админ нажимает «Опубликовать» на черновике
- **THEN** пост отправляется в канал, статус меняется на published, в банк создаётся markdown-файл
### Requirement: Список опубликованных с метриками
Веб MUST показывать опубликованные посты с их метриками (views своих постов из Bot API) и ссылкой на markdown-бандл.
#### Scenario: Просмотр опубликованного
- **WHEN** админ открывает список опубликованных
- **THEN** видны views/дата/ссылка на статью в банке
### Requirement: Быстрый интерфейс на Bootstrap
UI MUST строиться на Bootstrap 5.3 (CDN или локальный fallback) + Jinja2-шаблоны + HTMX для обновления списков без полной перезагрузки.
#### Scenario: Обновление списка кандидатов
- **WHEN** админ меняет статус черновика
- **THEN** список обновляется через HTMX-запрос без перезагрузки страницы
@@ -0,0 +1,71 @@
## 1. Каркас проекта и инфраструктура
- [ ] 1.1 Создать каталоги /opt/vesti/{crawler,classifier,publisher,web,bundles,db,sources,scripts,static}
- [ ] 1.2 Создать .venv и установить зависимости (requirements.txt): telethon, httpx, feedparser, fastapi, uvicorn, jinja2, python-dotenv, python-dateutil, python-telegram-bot, aiofiles
- [ ] 1.3 Создать /opt/vesti/.env (не в git): TG_API_ID=24276216, TG_API_HASH=..., VESTI_BOT_TOKEN=..., ADMIN_PASSWORD=...
- [ ] 1.4 Скопировать/создать sources/sources.yaml с источниками (формат /opt/news), первые направление: linux (linuxklub, linuxos_tg, dotfiles_linux, linux_education, LinuxMastery, linuxcamp_tg, gitgate, krxnotes)
- [ ] 1.5 Инициализировать гит в /opt/vesti (отдельно от openspec), .gitignore (.env, db/, media/, __pycache__)
- [ ] 1.6 Создать минимальную схему SQLite /opt/vesti/db/vesti.db (посты, каналы, состояния, черновики, опубликованные) — копия/адаптация /opt/news
Проверка: `ls /opt/vesti/{crawler,classifier,publisher,web}` существуют; `.venv/bin/python -c "import telethon, fastapi"`; `sqlite3 /opt/vesti/db/vesti.db ".tables"` показывает таблицы.
## 2. Telegram-краулер
- [x] 2.1 Написать crawler/telegram_crawler.py: Telethon через SOCKS5 :1080, чтение каналов из sources.yaml
- [x] 2.2 Инкрементальность: таблица tg_state(last_post_id, last_ts), чтение только новых постов
- [x] 2.3 Сбор метрик: text, ссылки (entities), views, reactions, дата, канал → SQLite
- [x] 2.4 Обработка 429/flood-wait: пауза, лимит ~20 постов/канал/запуск, сохранение состояния
- [x] 2.5 Создана собственная Telethon-сессия /opt/vesti/telegram/vesti.session (не /opt/news; там сессии нет), авторизована, используется краулером
- [x] 2.6 Обработка форвардов: анализ fwd_from, пропуск форвардов из своих источников, сохранение «чужих» с fwd_from_channel_id/fwd_from_post_id без скачивания медиа
- [x] 2.7 Типы контента (content_type: text/photo/video/document/voice/sticker) + скачивание медиа в media/ + дедуп: пост уже в БД (url/sha256) не дублируется, медиа не качается повторно
Проверка: ручной прогон `python -m crawler.telegram_crawler --channels linux` без ошибок; в БД появились посты с views/reactions; повторный запуск не дублирует.
## 3. Классификатор
- [x] 3.1 Написать classifier/keywords.py: словари направлений (linux: linux, kernel, distro, gnome, kde, apt, systemd, arch, ubuntu, fedora, debian...)
- [x] 3.2 Написать classifier/classify.py: вызов Ollama qwen3:8b-nothink (extra_body think:false), JSON: direction, relevance, interest, summary
- [x] 3.3 Обработка недоступности LLM: пост остаётся unclassified, не теряется
- [x] 3.4 Фолбэк без LLM: если Ollama недоступна, статья проходит словарный фильтр + базовые эвристики
Проверка: `python -m classifier.classify --direction linux` на тестовом посте возвращает валидный JSON; недоступная модель не роняет скрипт.
## 4. Бот-публикатор
- [x] 4.1 Написать publisher/card.py: формат карточки (заголовок, summary, направление, ссылка на источник) ≤ 4096 символов
- [x] 4.2 Написать publisher/bot.py: Bot API публикация (фото, если есть + текст-карточка)
- [x] 4.3 Конфигурация канала по шаблону: @dedinit_vesti_<направление>_<язык>_bot (для прототипа — linux_ru)
- [x] 4.4 Публикация ТОЛЬКО по явному подтверждению (флаг approved в БД); никакого автопостинга (publish() вызывается только из веба по клику)
- [x] 4.5 Опрос views опубликованных постов через Bot API → сохранение метрик (get_views)
Проверка: скрипт публикации с тестовым постом (сухой прогон без отправки: `--dry-run` печатает карточку); после реальной отправки в БД появляется message_id и растут views.
## 5. Веб-интерфейс
- [x] 5.1 Написать web/app.py (FastAPI): роуты /, /login, /candidates, /published, /metrics
- [x] 5.2 Авторизация: одна учётка, пароль из .env, session-cookie
- [x] 5.3 Шаблоны Jinja2 + Bootstrap 5.3 (CDN) + HTMX: base.html, login.html, candidates.html, published.html, metrics.html
- [x] 5.4 Кандидаты: список с фильтром по направлению/статусу; кнопки «Опубликовать»/«Отклонить» (HTMX, без перезагрузки)
- [x] 5.5 Опубликованные: список со views/датой/ссылкой на бандл (/bundle/{path}); метрики
- [ ] 5.6 Uvicorn на 127.0.0.1:8400 (systemd-юнит vesti-web.service — фаза запуска)
Проверка: `curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8400/login` → 200; без куки `/` → редирект на /login; HTMX-действие «Опубликовать» меняет статус без полной перезагрузки.
## 6. Банк статей (news-store)
- [x] 6.1 Написать web/store.py (или отдельный модуль): создание markdown-файла bundles/<direction>/<YYYY-MM>/<slug>.md с frontmatter
- [x] 6.2 Сохранение медиа в media/<slug>/ (если есть картинка), относительные пути в frontmatter
- [x] 6.3 Раздел «Источники» с ссылками на все URL
- [ ] 6.4 Git-инициализация /opt/vesti/bundles (репо vesti-bundles), авто-коммит после публикации (или очередь)
- [ ] 6.5 Связка: подтверждение в вебе → публикация в TG → создание бандла
Проверка: после публикации файл `bundles/linux/2026-09/<slug>.md` существует, содержит frontmatter (direction: linux, url, views), тело с summary и «Источники»; `git -C bundles log --oneline -1` показывает коммит.
## 7. Запуск и мониторинг
- [ ] 7.1 systemd-юниты: vesti-crawler.timer (каждые 15 мин), vesti-web.service, vesti-publisher (вызывается вебом)
- [ ] 7.2 Watchdog: no_agent cron, молчит если всё живо; шумит при dead↔alive канала/краулера (политика пользователя)
- [ ] 7.3 README.md в /opt/vesti с портами, командами, схемой (точность портов — приоритет пользователя)
- [ ] 7.4 Тест полного цикла: канал → краулер → классификатор → кандидат в вебе → подтверждение → публикация → бандл
Проверка: `systemctl --user status vesti-web` (или system) активен; watchdog молчит 3 дня подряд при здоровой системе; полный цикл проходит без ручных вмешательств кроме подтверждения.
@@ -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
- [ ] Переклассификация 466 своих без direction (limit 1000)
- [ ] Проверка БД: нет новых направлений вне канона
- [ ] `openspec validate unify-directions` — чисто
- [ ] Бэкап после правки
@@ -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/` — все места обработаны