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
@@ -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 пост)