openspec: архив 14 завершённых change-ов (веб-фиксы, crawler-queue, own-content-hub, publisher-service); спеки влиты в openspec/specs

This commit is contained in:
kpa39l
2026-09-16 17:01:00 +00:00
parent 584582a48c
commit 771f6a8276
88 changed files with 1632 additions and 3 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-15
@@ -0,0 +1,51 @@
# Design: sources-admin
## Модель данных и источник истины
- `sources/sources.yaml` — **источник истины** (в Git). Краулеры при запуске
вызывают `sync_sources_to_db()` → таблица `sources`.
- Любое веб-изменение: сначала пишем в yaml (`upsert_source_yaml` /
`delete_source_yaml` / toggle enabled), затем синкаем yaml → БД
(`sync_yaml_to_db_and_back`), чтобы бд-строка не расходилась.
## Изменения в sources/sources.py
1. `delete_source_db(slug)` — удаляет строку из `sources` по slug. Связанные
данные: `posts` (FK source_id) — задаём `source_id=NULL` (посты остаются
историей, без источника); `runs` — `source_id=NULL`; `rss_state` — DELETE;
`classifications` — не трогаем (привязаны к posts).
Порядок: `delete_source_yaml` (yaml) → `delete_source_db` (БД).
2. `set_source_enabled(slug, enabled)` — обновляет enabled в yaml
(через upsert_source_yaml с ключом enabled) и в БД (`sync_yaml_to_db_and_back`).
Пауза: enabled=false; снятие: enabled=true.
3. `add_or_update_source(data)` — валидация (slug обязателен, уникален;
crawler ∈ {telegram, rss}; priority ∈ {P0..P3}; для rss — feed_url обязателен)
→ upsert_source_yaml → sync_yaml_to_db_and_back → возврат slug.
## Веб-слой (web/app.py)
- `GET /sources` — страница (за auth): таблица всех источников (slug, name,
crawler, direction, lang, priority, enabled, url/feed_url, last_fetch/status),
форма добавления, кнопки действий. Шаблон `web/templates/sources.html`.
- `POST /sources/add` — добавление (форма). Redirect на /sources.
- `POST /sources/update/<slug>` — переименование/правка полей.
- `POST /sources/{slug}/toggle` — пауза/снятие с паузы.
- `POST /sources/{slug}/delete` — удаление (с подтверждением на стороне формы:
`onclick="return confirm(...)"`).
- Все POST — с `_require_auth`, следуют паттерну /crawlers (async + form).
- Валидация ошибок → flash message + редирект (не 500).
- Навбар: ссылка «Источники» между «Краулеры» и «Выйти».
## Шаблон sources.html
- Таблица + модальный диалог (Bootstrap) для add/edit (одна форма).
- Кнопки: Изменить (заполняет модалку), Удалить (confirm), Пауза/Снять (toggle).
- Приоритет — селект P0/P1/P2/P3; crawler — селект telegram/rss; direction —
селект из DIRECTIONS_CANON (keywords) + «—».
- Для rss: поле feed_url; для telegram: channel.
## Безопасность
- Пароль ADMIN_PASSWORD, как на /crawlers.
- slug — белый список [a-z0-9_-], валидация на добавление (иначе 400).
- Удаление — только POST + confirm (никаких GET-удалений).
@@ -0,0 +1,32 @@
# Proposal: sources-admin
## Why
Сейчас источники редактируются вручную в `sources/sources.yaml` (Git-файл).
Веба для управления нет: на /crawlers источники только читаются. Пользователь
хочет отдельную страницу управления источниками:
- добавлять новые источники (telegram/rss), указывая slug, name, url/feed_url, направление, приоритет;
- переименовывать (name), менять slug при необходимости;
- удалять (с подтверждением);
- ставить на паузу (enabled=false) и снимать с паузы;
- менять приоритет (priority P0–P3).
`sources.yaml` остаётся источником истины (краулеры при запуске делают
`sync_sources_to_db`). CRUD-функции в sources.py уже есть (upsert_source_yaml,
delete_source_yaml, db_source_to_yaml) — не хватает удаления/паузы в БД и
веб-слоя. Проблема: удаление источника из yaml оставляет его в БД (сирота с
постами/ранами); пауза = enabled=0 в yaml+БД.
## Success criteria
- Страница /sources (за auth): таблица всех источников, кнопки Добавить / Изменить / Удалить / Пауза / Снять с паузы.
- Добавление/изменение через модальную форму (slug, name, crawler, url/feed_url, direction, lang, priority).
- Удаление с подтверждением (confirm) и каскадной чисткой связанных данных.
- Каждое изменение пишется в sources.yaml И БД атомарно (yaml — источник истины).
- /crawlers продолжает работать (читает ту же таблицу).
## Out of scope
- Тестирование краулеров из веба (запуск оставлен на /crawlers).
- Управление направлениями (направления — код в keywords.py, не CRUD).
@@ -0,0 +1,92 @@
# Spec: sources-admin
## Purpose
Страница управления источниками в веб-интерфейсе VESTI: добавление, изменение,
удаление, пауза и смена приоритета источников без ручной правки YAML.
`sources.yaml` остаётся источником истины, веб пишет в него и синкает БД.
## ADDED Requirements
### Requirement: Просмотр списка источников
Страница /sources показывает таблицу всех источников с их полями и статусом.
#### Scenario: просмотр списка источников
- **Given** веб запущен, пользователь авторизован
- **When** он открывает `/sources`
- **Then** страница показывает таблицу всех источников: slug, name, crawler,
direction, lang, priority, enabled (пауза), url/feed_url, статус (last_fetch/error)
### Requirement: Добавление нового источника
Форма «Добавить» создаёт запись в sources.yaml и БД.
#### Scenario: добавление нового источника
- **Given** страница /sources
- **When** форма «Добавить» заполнена (slug, name, crawler=rss, feed_url, direction=tech, priority=P1)
- **And** отправлена
- **Then** запись появляется в `sources/sources.yaml` и таблице `sources` БД
- **And** страница показывает новый источник в таблице
#### Scenario: валидация добавления
- **Given** форма добавления с невалидным slug (`my source!`)
- **When** отправлена
- **Then** добавление отклонено, показана ошибка (flash), ничего не записано
- **And** slug принимается только `[a-z0-9_-]+`
### Requirement: Изменение полей источника
Форма «Изменить» обновляет name/priority/направление и пр. в yaml и БД.
#### Scenario: переименование / изменение полей
- **Given** существующий источник `lwn`
- **When** форма «Изменить» меняет `name` на «LWN Tech» и `priority` на P2
- **Then** изменения применяются в yaml и БД, таблица обновляется
### Requirement: Пауза и снятие с паузы
Переключатель enabled=false останавливает сбор источника, enabled=true возобновляет.
#### Scenario: пауза и снятие с паузы
- **Given** источник `lwn` (enabled=true)
- **When** пользователь нажимает «Пауза»
- **Then** `enabled=false` в yaml и БД
- **And** краулеры больше не собирают этот источник (`get_enabled_sources` исключает)
- **When** пользователь нажимает «Снять с паузы»
- **Then** `enabled=true`, сбор возобновляется
### Requirement: Удаление источника
Удаление (с подтверждением) убирает источник из yaml и БД, сохраняя его посты.
#### Scenario: удаление источника
- **Given** источник `opennet` с постами в БД
- **When** пользователь подтверждает удаление (confirm)
- **Then** запись удалена из yaml и sources БД
- **And** его посты НЕ удалены: `source_id=NULL` (история сохраняется)
- **And** rss_state для него удалён
#### Scenario: удаление без подтверждения
- **Given** форма удаления
- **When** confirm отклонён (Cancel)
- **Then** ничего не удалено, данные не меняются
### Requirement: Доступ без авторизации
Страница /sources защищена паролем, как остальные админ-страницы.
#### Scenario: доступ без авторизации
- **Given** пользователь не авторизован
- **When** он открывает `/sources`
- **Then** происходит редирект на /login (303)
### Requirement: Приоритет источника
Приоритет P0–P3 влияет на порядок обработки источников.
#### Scenario: приоритет
- **Given** источники с priority P0–P3
- **When** страница /sources загружена
- **Then** приоритеты отображаются и доступны для изменения (P0–P3)
- **And** /crawlers сортирует по (enabled, priority, slug) с учётом нового приоритета
### Requirement: Навигация
Ссылка «Источники» присутствует в навбаре.
#### Scenario: навигация
- **Given** любая страница веб (base.html)
- **When** открыт навбар
- **Then** есть ссылка «Источники» на `/sources`
@@ -0,0 +1,35 @@
# Tasks: sources-admin
## 1. sources.py — CRUD для БД + валидация
- [x] 1.1 `delete_source_db(slug)`: UPDATE posts/runs SET source_id=NULL, DELETE rss_state, DELETE sources.
Проверка: юнит-тест — после delete_source_db строки sources нет, rss_state нет, posts.source_id=NULL.
- [x] 1.2 `set_source_enabled(slug, enabled)`: обновить yaml (upsert с enabled) + sync_yaml_to_db_and_back.
Проверка: юнит — после toggle enabled в БД = 0/1, в yaml = false/true.
- [x] 1.3 `add_or_update_source(data)`: валидация (slug regex, crawler ∈ {telegram,rss}, priority ∈ P0..P3, rss→feed_url обязателен) → upsert yaml → sync.
Проверка: юнит — add 'opennet' (rss) → есть в yaml и БД; невалидный 400.
## 2. Веб-слой
- [x] 2.1 GET /sources — страница (таблица + форма). Шаблон sources.html.
Проверка: GET с auth → 200, в таблице все источники (10).
- [x] 2.2 POST /sources/add, /sources/update/<slug> — добавление/правка. Валидация, flash, редирект.
Проверка: POST add opennet → в БД/yaml появился; POST update — name изменился.
- [x] 2.3 POST /sources/{slug}/toggle — пауза (enabled 0) / снятие (enabled 1).
Проверка: toggle lwn → enabled=0 в БД и yaml; в /crawlers lwn помечен disabled.
- [x] 2.4 POST /sources/{slug}/delete — удаление с confirm. Каскад: posts/runs source_id=NULL, rss_state удалён.
Проверка: delete тестового источника → из yaml и БД исчез, посты не потеряны (source_id NULL).
- [x] 2.5 Навбар: ссылка «Источники» (была добавлена — проверена в base.html).
## 3. Интеграция, доки
- [x] 3.1 AGENT.MD / STATUS.md / TODO.md — /sources (добавлено/закрыто).
- [x] 3.2 Ссылка /sources в nav (проверка вёрстки).
- [x] 3.3 `openspec validate sources-admin` → valid.
- [x] 3.4 Рестарт vesti-web, ручная проверка GET /sources 200.
## Примечания
- sources.yaml — источник истины; каждое изменение пишется в yaml И БД.
- Удалённые посты НЕ удаляются (история), только source_id=NULL.
- Пауза не трогает уже собранные посты — только прекращает сбор (enabled=0).
- HTTP-тест: add→toggle(pause)→toggle(resume)→delete прошёл end-to-end (тест. источник test_tmp_src удалён, чисто).
@@ -0,0 +1,3 @@
schema: spec-driven
skip_specs: true
created: 2026-09-15
@@ -0,0 +1,106 @@
## Design
Файл: `/opt/vesti/web/templates/candidates.html`, правая панель
(`{% if selected %}`), блок действий (строки ~150–184).
### Текущая разметка
```html
<div class="mb-3 d-flex flex-wrap gap-2">
{% set hid %}...{% endset %}
{% if selected.status != 'published' %}
<form method="post" action="/posts/{{ selected.id }}/approve" id="approve-form">
{{ hid }}
<label class="form-label small text-muted mb-0">💬 Мой комментарий...</label>
<textarea class="form-control form-control-sm mt-1" name="comment" rows="2"
placeholder="Комментарий/анонс…">{{ selected.comment or '' }}</textarea>
<div class="mt-2 d-flex gap-2">
<button class="btn btn-success" type="submit" title="...">✅ Опубликовать</button>
<button class="btn btn-sm btn-outline-secondary" type="submit"
formaction="/posts/{{ selected.id }}/comment" title="...">💾 Сохранить комментарий</button>
</div>
</form>
{% endif %}
{% if selected.status != 'published' %}
<form method="post" action="/posts/{{ selected.id }}/reclassify">
{{ hid }}
<button class="btn btn-info" type="submit" title="...">🤖 Обработать моделью</button>
</form>
{% endif %}
{% if selected.status != 'published' %}
<form method="post" action="/posts/{{ selected.id }}/rewrite">
{{ hid }}
<button class="btn btn-outline-primary" type="submit" title="...">✍️ Переписать</button>
</form>
{% endif %}
{% if selected.status != 'rejected' and selected.status != 'published' %}
<form method="post" action="/posts/{{ selected.id }}/reject">
{{ hid }}
<button class="btn btn-outline-danger" type="submit">🚫 Отклонить</button>
</form>
{% endif %}
</div>
```
### Новая разметка
Кнопки действий выносятся в ОДИН flex-ряд НАД полем комментария; approve-форма
остаётся обёрткой только для «Опубликовать» + «Сохранить комментарий» + textarea
(поля `hid` нужны каждой форме; внутри approve-формы они уже есть).
```html
<div class="mb-3 d-flex flex-wrap gap-2">
{% set hid %}...{% endset %}
{% if selected.status != 'published' %}
<form method="post" action="/posts/{{ selected.id }}/reclassify">
{{ hid }}
<button class="btn btn-info" type="submit" title="...">🤖 Обработать моделью</button>
</form>
{% endif %}
{% if selected.status != 'published' %}
<form method="post" action="/posts/{{ selected.id }}/rewrite">
{{ hid }}
<button class="btn btn-outline-primary" type="submit" title="...">✍️ Переписать</button>
</form>
{% endif %}
{% if selected.status != 'rejected' and selected.status != 'published' %}
<form method="post" action="/posts/{{ selected.id }}/reject">
{{ hid }}
<button class="btn btn-outline-danger" type="submit">🚫 Отклонить</button>
</form>
{% endif %}
{% if selected.status != 'published' %}
<form method="post" action="/posts/{{ selected.id }}/approve" id="approve-form">
{{ hid }}
<div class="mt-0 d-flex gap-2">
<button class="btn btn-success" type="submit" title="...">✅ Опубликовать</button>
</div>
<label class="form-label small text-muted mb-0 mt-2">💬 Мой комментарий...</label>
<textarea class="form-control form-control-sm mt-1" name="comment" rows="2"
placeholder="Комментарий/анонс…">{{ selected.comment or '' }}</textarea>
<div class="mt-2">
<button class="btn btn-sm btn-outline-secondary" type="submit"
formaction="/posts/{{ selected.id }}/comment" title="...">💾 Сохранить комментарий</button>
</div>
</form>
{% endif %}
</div>
```
Порядок в ряду: «🤖 Обработать моделью» → «✍️ Переписать» → «🚫 Отклонить» →
«✅ Опубликовать» (кнопка публикации — правая, главная). Затем — поле
комментария и «💾 Сохранить комментарий».
Все кнопки остаются `type="submit"` в своих формах; `formaction` у
«Сохранить комментарий» сохраняется; логика видимости не меняется.
## Верификация
- `openspec validate candidates-buttons-order` — чисто.
- Рендер шаблона проверяется только ч/з сам сервис (TestClient/живой GET),
т.к. прямой рендер `tpl.get_template(...)` блокируется политикой.
- Перезапуск: `sudo systemctl restart vesti-web` (Jinja2-кеш!).
- Ручная проверка: `GET /candidates` → выбран неопубликованный пост → в
правой панели НАД полем комментария в одном ряду кнопки
Обработать/Переписать/Отклонить/Опубликовать, под textarea — «Сохранить
комментарий».
@@ -0,0 +1,46 @@
## Why
В прошлой сессии кнопку «Опубликовать» перенесли в approve-форму (вместе с
полем комментария). В результате в правой панели деталей кандидата кнопки
«Обработать моделью», «Переписать», «Отклонить» оказались ПОСЛЕ блока
комментария отдельным flex-рядом — визуально «съехали» и разорвали основной
ряд действий.
Пользователь: действия модератора должны быть над полем комментария, в одном
ряду с «Опубликовать».
## What Changes
- В `web/templates/candidates.html`, правая панель выбранного поста:
- Перенести кнопки «🤖 Обработать моделью», «✍️ Переписать», «🚫 Отклонить»
из отдельного flex-ряда ПОСЛЕ approve-формы в ОДИН ряд с кнопкой
«✅ Опубликовать» — НАД полем комментария.
- Итоговая структура блока действий (когда пост не опубликован/не отклонён):
```
[✅ Опубликовать] [🤖 Обработать моделью] [✍️ Переписать] [🚫 Отклонить]
💬 Мой комментарий (будет первым в канале):
[textarea комментария]
[💾 Сохранить комментарий]
```
- Логика видимости кнопок не меняется:
- «Опубликовать» — статус != published;
- «Обработать моделью», «Переписать» — статус != published;
- «Отклонить» — статус != rejected и != published.
## Why Not
- Не объединять все кнопки в одну approve-форму: «Сохранить комментарий»
использует отдельный эндпоинт (`/posts/{id}/comment`), а «Обработать
моделью»/«Переписать»/«Отклонить» — свои POST-эндпоинты; в одну форму их
не собрать без JS (а htmx/JS-сложная логика в проекте запрещены).
- Оставлять текущий порядок нельзя — кнопки визуально разорваны.
## Impact
- Файл: `web/templates/candidates.html` (правка только разметки, без JS и
эндпоинтов).
- Данные: без изменений.
- Сервис: `vesti-web` (:8400) — нужен перезапуск (Jinja2-кеш без --reload).
- Rollback: вернуть разметку и перезапустить сервис.
@@ -0,0 +1,10 @@
# candidates-buttons-order
- [x] Создан OpenSpec change (proposal/design) — `skip_specs: true`, без delta-спеки (правка только разметки, поведение кнопок не меняется)
- [x] `openspec validate candidates-buttons-order` — чисто
- [x] candidates.html: кнопки Обработать/Переписать/Отклонить перенесены над полем комментария, в одном ряду с Опубликовать
- [x] Проверка: рендер правой панели (живой GET :8400 после рестарта) — ряд кнопок над textarea комментария
- [x] Перезапуск `sudo systemctl restart vesti-web`
- [x] Ручная проверка в браузере: порядок кнопок над комментарием
- [x] Обновить STATUS.md / TODO.md
- [x] Бэкап (крон 2:45 делает сам, вручную НЕ запускать)
@@ -0,0 +1,3 @@
schema: spec-driven
created: 2026-09-14
skip_specs: true
@@ -0,0 +1,68 @@
## Design
Файл: `/opt/vesti/web/app.py`, функция `_fetch_candidates`.
Текущее (строки ~151-165):
```python
where = []
params = []
if direction:
where.append("p.direction=?")
params.append(direction)
if status:
where.append("p.status=?")
params.append(status)
if own in ("1", "0"):
where.append("p.is_own=?")
params.append(int(own))
if q:
where.append("(p.text LIKE ? OR p.summary LIKE ?)")
params += [f"%{q}%", f"%{q}%"]
w = ("WHERE " + " AND ".join(where)) if where else ""
```
Проблемы:
1. При `status=''` (по умолчанию) в кандидаты попадают published и rejected.
2. `own=''` (по умолчанию) не фильтрует is_own → в кандидаты попадают 846
постов собственного канала (is_own=1), которые не являются кандидатами.
Правка — два условия добавляются в WHERE всегда:
```python
where = []
params = []
# Кандидаты = только внешние (is_own=0) посты в статусе new.
# Свои посты канала (is_own=1) не являются кандидатами — это контент
# собственного канала, управляется отдельно (fan-out); published/rejected —
# уже решённые посты, им не место в очереди кандидатов.
where.append("p.is_own=0")
where.append("p.status='new'")
if direction:
where.append("p.direction=?")
params.append(direction)
if status and status != "new":
# Явный фильтр статуса (rejected/published) — просмотр решённых;
# для 'new' условие уже добавлено выше.
where.append("p.status=?")
params.append(status)
if own in ("1", "0"):
where.append("p.is_own=?")
params.append(int(own))
if q:
where.append("(p.text LIKE ? OR p.summary LIKE ?)")
params += [f"%{q}%", f"%{q}%"]
w = ("WHERE " + " AND ".join(where)) if where else ""
```
Примечание: `own='1'` (⭐ Свои) вернёт пустую выборку — это корректно:
свои посты не кандидаты. Фильтр own оставлен для обратной совместимости
(внешние = own='0' = все кандидаты).
## Верификация
- `openspec validate candidates-only-external-new` — чисто.
- Юнит:
`python -c "import sys; sys.path.insert(0,'/opt/vesti'); import web.app as A; c=A._db(); g,co,st=A._fetch_candidates(c,'','','','','date'); print([(x['key'],len(x['posts'])) for x in g]); print('new всего постов:', sum(len(x['posts']) for x in g)); c.close()"`
→ все группы: только внешние посты; is_own=1 посты отсутствуют, published/rejected отсутствуют.
- Живой: `GET /candidates` → в списке нет ⭐, нет «✅ Опубликованные», нет published/rejected.
- Рестарт `sudo systemctl restart vesti-web`.
@@ -0,0 +1,53 @@
## Why
Страница «Кандидаты» (`/candidates`) показывает посты, которые пользователь
должен рассмотреть и решить: публиковать или отклонить. Сейчас в списке
кандидатов отображаются посты, которым там не место:
1. **Опубликованные (`status='published'`)** — уже решённые посты. При
`status=''` (фильтр «Все», значение по умолчанию) запрос
`_fetch_candidates` НЕ фильтрует по статусу, поэтому published (6) и
rejected (3) попадают в список кандидатов. Пользователь: «в списке
кандидатов не должно быть одобренных к публикации постов».
2. **Свои посты канала @dedinit (`is_own=1`)** — 846 постов backfill'а
своего канала «Дед в АйТи» лежат в БД как `new`, но это контент из
собственного канала пользователя, а не кандидаты на публикацию.
Пользователь их не одобрял и не считает кандидатами: «при группировке
по дате все посты отображаются как „свои“, но они не мои, в канале их
нет, я их не одобрял». Отклонённые (rejected) — тоже уже решённые.
Итог: в списке кандидатов должны быть ТОЛЬКО внешние (is_own=0) посты в
статусе `new`.
## What Changes
В `web/app.py`, функция `_fetch_candidates`:
- При пустом `status` (фильтр «Все», используется по умолчанию и для
группировок source/date) — жёстко добавлять `p.status='new'` в WHERE.
Это исключает published и rejected из списка кандидатов.
- ВСЕГДА добавлять `p.is_own=0` в WHERE (независимо от фильтра own).
Свои посты (is_own=1) не являются кандидатами — это контент собственного
канала, управляется отдельно (fan-out). Исключаем их из списка кандидатов.
Фильтры `own`, `status` в форме остаются (они по-прежнему работают в рамках
внешних new-постов; `own='1'` теперь вернёт 0 постов — это ок, т.к. свои
посты не кандидаты).
## Why Not
- Не показывать published/rejected в отдельном разделе списка кандидатов:
для них есть страница «Опубликованные» (/published) и фильтры. Кандидаты —
это очередь на решение, не архив.
- Не удалять is_own посты из БД: они нужны для fan-out и «Своих»; меняется
только фильтрация на веб-странице кандидатов.
- Не трогать краулер/backfill: семантика is_own корректна (свой канал),
проблема только в отображении кандидатов.
## Impact
- Файл: `web/app.py`, `_fetch_candidates` (WHERE-условия).
- Данные: без миграций БД.
- Сервис: `vesti-web` (:8400) — перезапуск.
- Rollback: откатить WHERE-правку, перезапустить.
@@ -0,0 +1,10 @@
# candidates-only-external-new
- [x] Создан OpenSpec change (proposal/design) — skip_specs: true
- [x] web/app.py `_fetch_candidates`: в WHERE всегда `p.is_own=0 AND p.status='new'`; явный статус (rejected/published) — только просмотр решённых
- [x] `openspec validate candidates-only-external-new` — чисто
- [x] Юнит: `_fetch_candidates(...,'date')` → группы только внешние new; нет ⭐, нет published/rejected
- [x] Живой: GET /candidates → нет опубликованных/отклонённых/своих в списке
- [x] Рестарт vesti-web
- [x] Обновить STATUS.md / TODO.md
- [x] Git push в gitverse
@@ -0,0 +1,3 @@
schema: spec-driven
skip_specs: true
created: 2026-09-15
@@ -0,0 +1,117 @@
## Design
### 1. БД: колонка is_read
`db/schema.sql` — в CREATE TABLE posts добавить:
```sql
is_read INTEGER DEFAULT 0, -- 1 = пост просмотрен (открыт в правой панели)
```
Миграция существующей БД — при старте веб-приложения (идемпотентно):
в начале `web/app.py` (после `DB_PATH`) или в `_db()`:
```python
def _migrate():
"""Идемпотентные миграции при старте (веб-сервис)."""
conn = sqlite3.connect(DB_PATH, timeout=10)
cols = [r[1] for r in conn.execute("PRAGMA table_info(posts)").fetchall()]
if "is_read" not in cols:
conn.execute("ALTER TABLE posts ADD COLUMN is_read INTEGER DEFAULT 0")
conn.commit()
conn.close()
```
Вызвать один раз при импорте (перед `app = FastAPI(...)` или после).
### 2. Дефолтная группировка — дата
- `GET /candidates`: параметр `group_by: str = "date"` (было `"source"`).
- `POST /candidates/select`: `group_by: str = Form("date")` (было `"source"`).
- `POST /candidates/bulk`: `group_by: str = Form("date")` — тоже, чтобы
формы левой панели (bulk) сохраняли выбранную группировку по умолчанию.
### 3. Прочтение при выборе поста
В `GET /candidates`: когда `selected` задан и пост найден — пометить
прочитанным ДО рендера (чтобы карточка сразу стала обычной):
```python
if selected:
conn.execute("UPDATE posts SET is_read=1 WHERE id=? AND is_read=0", (selected,))
conn.commit()
```
(селект уже найден выше; UPDATE до `conn.close()`).
В `POST /candidates/select` — после редиректа на
`/candidates?selected=id&group_by=...` это покроет выбор; но select —
обычная форма, редирект идёт на GET, который и помечает. Отдельный UPDATE
в select не нужен (GET сделает).
### 4. Шаблон: карточки списка
`web/templates/candidates.html`, блок `{% for p in g.posts %}`:
Сейчас (строки 78–92):
```html
<div class="list-group-item d-flex align-items-start">
<a class="flex-grow-1 text-decoration-none{% if selected and selected.id == p.id %} fw-bold text-primary{% endif %}"
href="...">
<span class="d-block text-truncate">{{ (p.text or '')[:90] }}</span>
...
</a>
<input type="checkbox" ...>
</div>
```
Стало:
```html
<div class="list-group-item d-flex align-items-start{% if selected and selected.id == p.id %} active-row{% endif %}{% if not p.is_read %} fw-bold{% endif %}">
<a class="flex-grow-1 text-decoration-none"
href="...">
<span class="d-block text-truncate">{{ (p.text or '')[:90] }}</span>
...
</a>
<input type="checkbox" ...>
</div>
```
- `is_read=0` → `fw-bold` на всей карточке (текст жирный);
- `is_read=1` → обычный;
- выбранный → класс `active-row` → светло-голубая заливка.
Класс `active-row` — в base.html или candidates.html `<style>`:
```css
.list-group-item.active-row {
background-color: #e3f2fd; /* светло-голубой */
/* не перебиваем hover/оригинал: border, text — наследуются */
}
```
(используем кастомный класс, т.к. bootstrap `active` меняет цвет текста на
белый — не нужно; `bg-info`/`bg-light` — слишком сильные/неоднородные.)
### 5. Жирный шрифт только для непрочитанного
`fw-bold` на контейнере карточки делает жирным и текст, и метаданные —
это нормально для почтового клиента (непрочитанное письмо — весь блок
жирный). Ссылка остаётся `text-decoration-none`, цвет «primary» у
выбранного убираем (заливка вместо цвета).
## Верификация
- `openspec validate candidates-read-flag` — чисто.
- Миграция: запуск приложения (TestClient/import) создаёт колонку is_read;
существующие посты получают 0.
- Тест: GET /candidates?selected=N → в HTML карточка N имеет `active-row`
и НЕ `fw-bold` (после прочтения); у непрочитанного поста — `fw-bold`.
- Дефолт: GET /candidates (без group_by) → в шаблоне `group_by == 'date'`,
группы подписаны «Сегодня/Вчера/…».
- Перезапуск `sudo systemctl restart vesti-web`.
- Ручная проверка: список по умолчанию по дате; непрочитанные жирные;
открытый пост — голубая заливка; после открытия карточка перестаёт быть
жирной.
@@ -0,0 +1,45 @@
## Why
Список кандидатов — «почтовый клиент»: слева список карточек, справа детали.
Сейчас все карточки выглядят одинаково, не видно, какие посты уже смотрели,
а какой пост выбран (открыт) — не выделен, кроме цвета текста ссылки.
Пользователь хочет как в почтовых клиентах:
- непрочитанные посты — жирным шрифтом;
- прочитанные — обычным (как сейчас);
- выбранный (открытый) пост — светло-голубая заливка всей карточки.
Плюс: по умолчанию группировка должна быть ПО ДАТЕ (хронология получения),
а не по источнику.
## What Changes
- БД: колонка `posts.is_read INTEGER DEFAULT 0` (0 = не прочитан, 1 = прочитан).
- Web:
- выбор поста (GET /candidates?selected=N или POST /candidates/select)
помечает его `is_read=1`;
- карточка непрочитанного поста в списке — жирный текст (`fw-bold` на
заголовке/тексте), прочитанного — обычный (как сейчас);
- карточка выбранного поста — светло-голубая заливка всей карточки
(вместо текущего `fw-bold text-primary` на ссылке);
- дефолт `group_by` в GET /candidates и POST /candidates/select — `date`
вместо `source` (хронологический порядок получения: сегодня → вчера →
неделя → раньше).
## Why Not
- Не менять статус при публикации/отклонении: прочитанность — отдельный
флаг просмотра, не связан со статусом.
- Не делать авто-прочтение всех при заходе на страницу: только выбранный
пост становится прочитанным (как в почте).
- Группировка по дате — дефолт, но переключатель (источник/статус) остаётся.
## Impact
- Файлы: `db/schema.sql` (колонка), `web/app.py` (миграция при старте +
дефолт группировки + прочтение при выборе), `web/templates/candidates.html`
(классы карточек).
- Данные: ALTER TABLE (новые посты is_read=0 — непрочитанные; старые
после миграции тоже 0).
- Сервис: `vesti-web` (:8400) — перезапуск.
- Rollback: убрать колонку/правки и перезапустить.
@@ -0,0 +1,15 @@
# candidates-read-flag
- [x] Создан OpenSpec change (proposal/design) — `skip_specs: true`
- [x] `openspec validate candidates-read-flag` — чисто
- [x] db/schema.sql: колонка posts.is_read INTEGER DEFAULT 0
- [x] web/app.py: миграция is_read при старте (_migrate, идемпотентно)
- [x] web/app.py: GET /candidates и POST select/bulk: group_by по умолчанию 'date'
- [x] web/app.py: при selected → UPDATE posts SET is_read=1
- [x] candidates.html: непрочитанные — fw-bold, выбранный — active-row (светло-голубая заливка)
- [x] base.html: CSS .list-group-item.active-row (#dcecfc)
- [x] Тест (TestClient): миграция, прочтение при selected, дефолт date, классы карточек
- [x] Перезапуск `sudo systemctl restart vesti-web`
- [x] Ручная проверка в браузере: дата по умолчанию, жирные непрочитанные, голубая заливка выбранного
- [x] Обновить STATUS.md / TODO.md
- [x] Бэкап (крон 2:45 делает сам, вручную НЕ запускать)
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-15
@@ -0,0 +1,107 @@
# Design: crawler-queue
## Approach
Двухфазный пайплайн поверх существующей модели данных (БЕЗ новой таблицы-очереди):
```
Фаза 1 (сбор) Фаза 3 (обработка, ПАРАЛЛЕЛЬНО)
sources.yaml ──► ┌──────────────┐
telegram_crawler ──► │ ThreadPool │──► posts(classified=0)
rss_crawler ──► │ (8 воркеров)│──► keywords → Ollama → direction
│ └──────────────┘ + trafilatura (полный текст)
▼
posts (status='new', classified=0) = ОЧЕРЕДЬ
```
- **Очередь = таблица `posts`** уже существует (`status='new'` + `classified=0`).
Размер очереди: `SELECT COUNT(*) FROM posts WHERE classified IS NULL OR classified=0`.
- **Фаза 3** — новый `crawler/worker.py`: ThreadPoolExecutor(8), каждый воркер
claim'ит посты (`UPDATE posts SET classified=-1 WHERE id=? AND classified=0`
→ атомарный claim, без дублей), классифицирует (keywords → Ollama), пишет
direction/relevance/interest/summary, ставит classified=1. Упавшие → classified=0
обратно (ретрай).
- **Фаза 1** — `crawler/crawl_sources.py`: обёртка, запускающая telegram_crawler
и rss_crawler (сбор источников → посты в очередь). Раздельные запуски.
- **Страница `/crawlers`** — FastAPI route в `web/app.py` + шаблон
`web/templates/crawlers.html`: таблица источников (из `sources` + `rss_state`),
размер очереди (pending/processing/done), кнопки «Запустить сейчас»
(POST /crawlers/run) и «Сбросить dead» (POST /crawlers/reset).
## Files
```bash
# Новое
crawler/worker.py # фаза 3: ThreadPoolExecutor, claim, классификация
crawler/crawl_sources.py # фаза 1: запуск telegram+rss краулеров (сбор в очередь)
web/templates/crawlers.html # страница /crawlers
# Изменения
web/app.py # routes: GET /crawlers, POST /crawlers/run, POST /crawlers/reset
sources/sources.py # + get_rss_state(), + reset_source_status()
AGENT.MD / STATUS.md / TODO.md / WALKTHROUGH.md # доки
openspec/changes/crawler-queue/ # этот change
```
## Worker (фаза 3)
```python
# crawler/worker.py
from concurrent.futures import ThreadPoolExecutor, as_completed
import sqlite3
def claim_post(conn, worker_id) -> row | None:
# атомарный claim: одна строка — один воркер
conn.execute("BEGIN IMMEDIATE")
row = conn.execute(
"SELECT id, text, views, reactions_total, is_own FROM posts "
"WHERE classified IS NULL OR classified=0 "
"ORDER BY is_own DESC, id LIMIT 1").fetchone()
if row:
conn.execute("UPDATE posts SET classified=-1 WHERE id=?", (row["id"],))
conn.commit()
return row
def process_one(row) -> dict: # classify_text из classifier.classify
...
def run(workers: int = 8, limit: int = 200):
with ThreadPoolExecutor(max_workers=workers) as ex:
futs = [ex.submit(work_loop, worker_id=i) for i in range(workers)]
for f in as_completed(futs):
...
```
Каждый воркер:
1. `claim_post` → строка post (classified=0 → -1).
2. `classify_text` (keywords → Ollama; трафилатура для summary-only).
3. UPDATE posts SET direction=?, relevance=?, interest=?, summary=?, classified=1 WHERE id=?
4. При исключении → UPDATE posts SET classified=0 WHERE id=? (вернуть в очередь).
## Страница /crawlers
| Источник | slug | name | crawler | status | last_fetch | last_error | Приоритет | Действия |
Снизу — карточки очереди:
- Pending: COUNT(classified IS NULL OR 0)
- Processing: COUNT(classified=-1)
- Done (сегодня): COUNT(classified=1 AND fetched_at >= today)
Кнопки:
- «Запустить сейчас» → POST /crawlers/run → запускает worker.run(8) синхронно (или
через subprocess в фоне), редирект на /crawlers.
- «Сбросить dead» → POST /crawlers/reset → UPDATE sources SET status='alive', error_count=0
WHERE status='dead'.
## CLI
```bash
.venv/bin/python -m crawler.worker --workers 8 --limit 200 # фаза 3, параллельно
.venv/bin/python -m crawler.crawl_sources --all # фаза 1, сбор в очередь
```
## Verification
- [ ] `openspec validate crawler-queue` → 0 ошибок
- [ ] `worker.run(8)` обрабатывает ≥5 постов из очереди; ПОВТОРНЫЙ запуск — 0 новых (все classified=1)
- [ ] Дубли не возникают: 100 постов, 8 воркеров → 100 строк обновлены, 0 пропущено
- [ ] Страница /crawlers: показывает таблицу источников, размер очереди; кнопка «Сбросить dead» обнуляет error_count
@@ -0,0 +1,46 @@
# Proposal: crawler-queue
## Why
Сейчас фазы сбора и обработки не разделены: каждый краулер (telegram/rss) сам
собирает посты и сам же (через `classifier.classify`) разбирает их на
классификацию. Это последовательно: весь прогон — один поток, один источник за
раз. При этом самая дорогая часть — фаза 3 (классификация через Ollama,
дотягивание текста трафилатурой) — выполняется последовательно и без видимости
процесса: непонятно, сколько кандидатов в очереди, какие источники живы, что
упало.
Пользователь хочет:
1. Отдельная страница `/crawlers` — статус работы краулеров (alive/dead,
last_fetch, ошибки) и размер очереди.
2. Двухфазный сбор: (1) краулеры проходят по источникам → ищут новых кандидатов;
(2) найденное кладётся в очередь; (3) воркеры разбирают очередь в НЕСКОЛЬКО
ПОТОКОВ.
Анализ текущего кода:
- Очередь фазы 2 уже существует — это `posts` со `status='new'` и
`classified IS NULL OR classified=0` (классификатор выбирает именно их,
`SELECT ... WHERE classified IS NULL OR classified=0 LIMIT ?`).
- Отдельная таблица `crawl_queue` НЕ нужна — она дублировала бы `posts`.
«Размер очереди» = `COUNT(*) FROM posts WHERE classified IS NULL OR classified=0`.
- Чего нет: (а) параллельной фазы 3 (ThreadPoolExecutor), (б) страницы `/crawlers`,
(в) разделения «сбор» и «обработка» как независимых запусков.
## Goal
- Параллельная фаза 3: воркеры (N потоков) разбирают очередь
`posts(classified=0)` — классификация (keywords → Ollama) + дотягивание текста
(trafilatura) по пайплайну `classifier.classify`.
- Страница `/crawlers` (веб, за auth): таблица источников (slug, name, crawler,
status, last_fetch, last_error, error_count, priority), размер очереди
(pending/processing/done), кнопки «Запустить сейчас» и «Сбросить dead».
- Разделение: `crawler/worker.py` (фаза 3, N потоков) и `crawler/crawl_sources.py`
(фаза 1: собрать новых кандидатов в очередь) — независимые запуски.
- Терминология (для доков): true-конкурентность на IO-bound задачах через потоки;
GIL не мешает, т.к. фаза 3 — ожидание сети.
## Non-goals
- Не вводим Redis/RabbitMQ/брокеры — SQLite-очередь (claim по `posts`) достаточна.
- Не выносим в микросервисы — всё в рамках существующего веба/краулера.
- Не переписываем telegram_crawler; RSS-краулер уже работает.
@@ -0,0 +1,74 @@
# Spec: crawler-queue
## Purpose
Двухфазный пайплайн сбора и обработки новостей: краулеры (фаза 1) собирают
кандидатов в очередь (`posts` со `status='new'` и неклассифицированные), воркеры
(фаза 3) разбирают её параллельно (ThreadPoolExecutor). Отдельная страница
`/crawlers` показывает статус источников и размер очереди.
## ADDED Requirements
### Requirement: Параллельная фаза 3 (воркеры)
Система MUST предоставлять `crawler/worker.py` с пулом `ThreadPoolExecutor(max_workers=N)`,
где каждый воркер атомарно забирает пост из очереди (`UPDATE posts SET classified=-1
WHERE id=? AND classified=0`), классифицирует его (`classify_text`: keywords → Ollama,
trafilatura для summary-only) и пишет результат (direction/relevance/interest/summary,
classified=1). При исключении посте MUST возвращаться в очередь (classified=0).
#### Scenario: Параллельная обработка очереди
- **GIVEN** 100 постов со `classified=0`
- **WHEN** `python -m crawler.worker --workers 8 --limit 200`
- **THEN** все 100 постов обработаны (classified=1), дублей нет (каждый обработан ровно 1 раз)
#### Scenario: Сбой воркера
- **GIVEN** пост, у которого `classify_text` бросает исключение (Ollama недоступна)
- **WHEN** воркер обрабатывает пост
- **THEN** пост возвращается в очередь (classified=0), воркер продолжает работу, запуск не падает
### Requirement: Очередь на основе posts (без новой таблицы)
«Размер очереди» MUST вычисляться из существующей таблицы `posts`:
`SELECT COUNT(*) FROM posts WHERE classified IS NULL OR classified=0` (pending),
`classified=-1` (processing), `classified=1 AND fetched_at >= date('now')` (done today).
Новая таблица для очереди НЕ создаётся — она дублировала бы `posts`.
#### Scenario: Размер очереди
- **GIVEN** в posts 10 новых (classified=0) и 2 в обработке (classified=-1)
- **WHEN** страница /crawlers запрашивает размер очереди
- **THEN** pending=10, processing=2, done today=0
### Requirement: Страница /crawlers
Веб MUST предоставлять `GET /crawlers` (за аутентификацией): таблица источников
(slug, name, crawler, status, last_fetch, last_error за 200 симв., error_count,
priority; для rss — etag/modified/last_build_date из rss_state) и карточки очереди
(pending/processing/done). Кнопка «Сбросить dead» (POST /crawlers/reset) MUST
устанавливать sources.status='alive', error_count=0, last_error=NULL для всех
источников со status='dead'.
#### Scenario: Просмотр статуса
- **GIVEN** источник lwn (rss, alive) и 15 новых постов в очереди
- **WHEN** GET /crawlers
- **THEN** страница показывает lwn с статусом alive, размер очереди 15
#### Scenario: Сброс dead-источников
- **GIVEN** источник со status='dead', error_count=7
- **WHEN** POST /crawlers/reset
- **THEN** источник становится alive, error_count=0, last_error=NULL
### Requirement: Фаза 1 (сбор) отдельно от фазы 3 (обработка)
Система MUST предоставлять `crawler/crawl_sources.py` — запуск сбора всех
включённых источников (telegram_crawler + rss_crawler) без классификации;
новые посты попадают в очередь (posts, classified=0). Обработка (фаза 3)
запускается отдельно (`crawler/worker.py`).
#### Scenario: Сбор без классификации
- **GIVEN** включённые источники telegram и rss
- **WHEN** `python -m crawler.crawl_sources --all`
- **THEN** новые посты добавлены в posts с classified=0, классификация НЕ запущена
## NOT Requirements
- НЕ создаём отдельную таблицу очереди (`crawl_queue`) — используется `posts`.
- НЕ вводим Redis/RabbitMQ/брокеры — атомарный claim через SQLite достаточен.
- НЕ переписываем telegram_crawler/rss_crawler (фаза 1) — они уже работают.
- НЕ выносим воркеры в отдельный процесс/сервис — модуль в том же проекте.
@@ -0,0 +1,40 @@
# Tasks: crawler-queue
## 1. Воркер (фаза 3)
- [x] 1.1 crawler/worker.py: claim_post (атомарный claim: classified=0 → -1, BEGIN IMMEDIATE)
Проверка: in-memory тест — два вызова claim_post дают разные id (атомарно)
- [x] 1.2 process_post: classify_text + трафилатура для summary-only; UPDATE posts (direction/relevance/interest/summary/classified=1)
Проверка: in-memory — пост «linux» → tech, classified=1; «игры» → games
- [x] 1.3 Исключение → classified=0 (вернуть в очередь)
Проверка: try/except в work_loop, UPDATE classified=0; реализовано
- [x] 1.4 run(workers=8, limit=200): ThreadPoolExecutor, as_completed, счётчики (processed/classified/llm_ok)
Проверка: `python -m crawler.worker --workers 4 --limit 2` → 8 обработано; без ошибок
## 2. Сбор (фаза 1)
- [x] 2.1 crawler/crawl_sources.py: запуск telegram_crawler + rss_crawler (сбор новых кандидатов в очередь)
Проверка: `python -m crawler.crawl_sources --all` → запускает оба; `--crawler rss` — только RSS
## 3. Страница /crawlers
- [x] 3.1 web/app.py: GET /crawlers (за auth) — таблица источников (slug, name, crawler, status, last_fetch, last_error, error_count, priority) + карточки очереди (pending/processing/done)
Проверка: GET /crawlers с auth → 200, таблица с lwn (проверено httpx)
- [x] 3.2 web/templates/crawlers.html — шаблон (таблица + карточки + кнопки)
- [x] 3.3 POST /crawlers/run → запуск worker.run (фаза 3); POST /crawlers/reset → sources.status='alive', error_count=0
Проверка: POST /crawlers/run → воркер реально стартует (logs/worker.log), 200/302; reset — UPDATE
## 4. Интеграция и доки
- [x] 4.1 AGENT.MD — команды worker/crawl_sources (добавлены)
- [x] 4.2 STATUS.md / TODO.md — страница /crawlers, параллельная фаза 3 (обновлены)
- [x] 4.3 `openspec validate crawler-queue` → 0 ошибок
- [x] 4.4 TODO.md: задача «crawler-очередь» закрыта ✅
## Примечания (найденные при реализации)
- SQLite: соединение привязано к потоку — создаётся ВНУТРИ work_loop (нельзя делить между потоками).
- Локальная Ollama (qwen3:8b) держит 1 слот — 8 параллельных LLM-запросов → таймауты (посты возвращаются в очередь, не теряются). Добавлен `LLM_SEM` (threading.BoundedSemaphore, MAX_CONCURRENT_LLM=2).
- Очередь = posts (classified IS NULL OR 0) — отдельная таблица НЕ нужна (дублирование).
- error_count/etag для RSS — из rss_state (в sources их нет); запрос /crawlers использует COALESCE.
- **SOURCE_RULES (classifier/keywords.py)**: правило источника с приоритетом над словарём и LLM. `lwn → tech` — все посты LWN (технологическое СМИ) получают направление tech детерминированно, без LLM (method='source-rule'). Работает в worker.py и старом CLI classifier.classify (оба JOIN sources → source_slug).
@@ -0,0 +1,3 @@
schema: spec-driven
skip_specs: true
created: 2026-09-14
@@ -0,0 +1,62 @@
## Design
Файл: `/opt/vesti/web/app.py`, функция `_fetch_candidates`.
Текущий код (строки ~219-227):
```python
grouped = []
for k, it in itertools.groupby(sorted(flat, key=keyf), key=keyf):
items = list(it)
grouped.append({
"key": k,
"label": _group_label(group_by, k),
"status": items[0]["status"], # ← БАГ: статус первого поста
"posts": items,
})
```
Проблема: `items[0]["status"]` берёт статус первого поста в группе. При
`group_by=date` сортировка идёт по дате (свежие сверху), и первый пост группы —
свежайший, который может быть `published`/`rejected`, хотя вся остальная группа
состоит из `new`. Шаблон рисует бейдж группы только для `new`/`rejected`,
поэтому группа с первым published-постом остаётся без бейджа и выглядит как
«опубликованная».
Правка — считать статус группы как доминирующий среди всех постов группы:
```python
def _group_status(items):
"""Статус группы: приоритет new > rejected > published > первый."""
st = [p["status"] for p in items]
for s in ("new", "rejected", "published"):
if s in st:
return s
return items[0]["status"]
grouped = []
for k, it in itertools.groupby(sorted(flat, key=keyf), key=keyf):
items = list(it)
grouped.append({
"key": k,
"label": _group_label(group_by, k),
"status": _group_status(items),
"posts": items,
})
```
Логика приоритета: если в группе есть хотя бы один `new` — группа «новые»
(это важно для группировки по дате, где почти всегда есть новые кандидаты
наряду с опубликованными/отклонёнными). Если новых нет, но есть отклонённые —
«откл.». Иначе «опубл.» (для фильтра status=published).
## Верификация
- `openspec validate fix-date-group-status` — чисто.
- Юнит-проверка (без перезапуска сервиса):
`python -c "import sys; sys.path.insert(0,'/opt/vesti'); import web.app as A; c=A._db(); g,_,_=A._fetch_candidates(c,'','','','','date',limit=200); [print(x['key'],x['status']) for x in g]; c.close()"`
→ группа `week` должна иметь `status='new'` (не `published`).
- Перезапуск: `sudo systemctl restart vesti-web`.
- Ручная проверка в браузере: `GET /candidates?group_by=date` → группа
«Ранее на этой неделе» показывает бейдж «новые»; правая панель выбранного
поста корректно показывает кнопку «Опубликовать», если пост ещё не опубликован.
@@ -0,0 +1,51 @@
## Why
При переключении группировки списка кандидатов на «дата» (`/candidates?group_by=date`)
статусные бейджи групп и правая панель вводят в заблуждение: часть групп
(например, «Ранее на этой неделе», 52 поста) не получает бейджа статуса,
а выбранный пост в правой панели может выглядеть как уже «опубликованный»
(кнопка «Опубликовать» скрыта), хотя это — обычный кандидат.
Причина: в `_fetch_candidates` статус группы вычисляется как
`items[0]["status"]` — статус ПЕРВОГО (свежайшего) поста в группе.
При группировке по дате первым в группе оказывается самый свежий пост,
который может быть уже опубликованным или отклонённым, хотя вся остальная
группа — новые кандидаты. Шаблон `candidates.html` рисует бейдж только для
`new`/`rejected`, поэтому группа с первым published-постом выглядит «без
статуса» (как опубликованная), а у selected-published поста скрыта кнопка
«Опубликовать».
Наблюдаемый симптом пользователя: «при переключении на сортировку по дате
все кандидаты становятся отмеченными опубликовано и источник „Дед в АйТи“».
## What Changes
- В `web/app.py` (`_fetch_candidates`): статус группы считать не по первому
посту, а как ДОМИНИРУЮЩИЙ статус внутри группы:
- если в группе есть `new` → статус группы `new`;
- иначе если есть `rejected` → `rejected`;
- иначе если есть `published` → `published`;
- иначе — статус первого поста (запасной вариант).
- Шаблон `candidates.html` остаётся без изменений (для `published` бейдж не
рисуется — это корректно: опубликованные не показываются в кандидатах как
активные). Исправление логики устраняет и ложный «published»-вид группы, и
неправильный вид правой панели для не-публикованных постов.
- При `group_by=date` группа «Ранее на этой неделе» с 52 постами (51 new +
1 published) теперь получит бейдж «новые».
## Why Not
- Не менять сортировку постов внутри группы на статус: это сломает ожидание
«свежие сверху» для группировки по дате.
- Не прятать published-посты из date-группировки полностью: пользователь
должен видеть, что именно опубликовано в этот период.
- Минимальная правка в одном месте (`_fetch_candidates`) — не трогаем шаблон
и не меняем схему БД.
## Impact
- Файл: `web/app.py`, функция `_fetch_candidates`, блок вычисления
`"status"` группы.
- Данные: без миграций БД.
- Сервис: `vesti-web` (:8400) — требуется перезапуск.
- Rollback: откатить правку в `_fetch_candidates` и перезапустить сервис.
@@ -0,0 +1,10 @@
# fix-date-group-status
- [x] Создан OpenSpec change (proposal/design) — `skip_specs: true`, без delta-спеки (багфикс без изменения поведения контракта)
- [x] web/app.py: статус группы в `_fetch_candidates` — доминирующий по группе (new > rejected > published)
- [x] `openspec validate fix-date-group-status` — чисто
- [x] Юнит-проверка: `_fetch_candidates(...,'date')` → группа week имеет status='new'
- [x] Перезапуск vesti-web (`sudo systemctl restart vesti-web`)
- [x] Ручная проверка: `GET /candidates?group_by=date` — группа «Ранее на этой неделе» с бейджем «новые»; у selected не-опубликованного поста есть кнопка «Опубликовать»
- [x] Обновить STATUS.md / TODO.md
- [x] Бэкап: `sudo /opt/vesti/backup.sh`
@@ -0,0 +1,3 @@
schema: spec-driven
created: 2026-09-14
skip_specs: true
@@ -0,0 +1,93 @@
## Дизайн
### 1. docker-compose.yml — весь каталог media
Было:
```yaml
volumes:
- ../../media/media:/srv/publisher/media:ro
```
Стало:
```yaml
volumes:
# ВЕСЬ каталог медиа (не media/media): краулер с 2026-09-12 качает в
# media/<file>, старые лежали в media/media/<file>. Обе раскладки
# доступны в контейнере: /srv/publisher/media/<file> и
# /srv/publisher/media/media/<file>.
- ../../media:/srv/publisher/media:ro
```
### 2. main.py — резолвинг медиа-пути в контейнере
В `publish()` для Telegram-каналов заменить прямую проверку на устойчивый
поиск:
Было:
```python
if req.card.media and Path(req.card.media).exists():
media_msg = telegram.send_media(ch, req.card.media, caption=req.card.text[:1000])
res.media_message_id = media_msg
msg = telegram.send_message(ch, req.card.text)
res.message_id = msg
```
Стало — хелпер `_resolve_media(card.media)`:
```python
def _resolve_media(media: str | None) -> str | None:
"""Находит реальный путь медиа-файла в контейнере (CWD /srv/publisher).
Пробует варианты в порядке приоритета:
1. media как есть -> /srv/publisher/media/<file> (новая раскладка)
2. media/media/<basename> -> старая раскладка (media/media/<file>)
Возвращает существующий путь или None (медиа молча пропускается).
"""
if not media:
return None
base = Path(media).name
# card.media из веба — относительный 'media/<file>' → резолвится от CWD /srv/publisher
cands = [Path(media), Path("media") / base, Path("media/media") / base]
for c in cands:
try:
if c.exists() and c.is_file():
return str(c)
except OSError:
continue
return None
```
Использование:
```python
media_path = _resolve_media(req.card.media)
if media_path:
media_msg = telegram.send_media(ch, media_path, caption=req.card.text[:1000])
res.media_message_id = media_msg
msg = telegram.send_message(ch, req.card.text)
res.message_id = msg
else:
msg = telegram.send_message(ch, req.card.text)
res.message_id = msg
```
Для GoToSocial — тот же `_resolve_media` в `_publish_gotosocial`:
```python
media_ids = []
media_path = _resolve_media(card.media)
if media_path:
media_ids.append(gotosocial.upload_media(media_path))
```
### 3. Что НЕ меняем
- `publisher/card.py` — медиа-нормализация на стороне веба остаётся.
- БД, `media_path` — без миграций.
- `web/app.py`, `web/store.py` — без изменений.
## Порядок деплоя
1. Правки compose + main.py.
2. `docker compose -f services/publisher/docker-compose.yml up -d` (volume
изменился — контейнер пересоздастся).
3. Проверка: `docker exec vesti-publisher python -c "import os; print(os.path.exists('media/linux_education_2772.jpg'))"` → True.
4. e2e: publish поста (текст+медиа) в канал → `media_message_id` задан, затем
удалить тестовые сообщения (delete_message).
@@ -0,0 +1,69 @@
## Why
Пост #1017 (linux) опубликован без картинки: в Telegram ушёл только текст, хотя
у поста есть медиа (`media_path='media/linux_education_2772.jpg'`, файл на хосте
`/opt/vesti/media/linux_education_2772.jpg`, 151 КБ).
Причина: Docker-контейнер `vesti-publisher` монтирует
`../../media/media:/srv/publisher/media` — **внутренний** каталог `media/media/`.
С 2026-09-12 краулер качает медиа в `media/<file>` (внешний каталог), а не в
`media/media/<file>`. В контейнере (CWD `/srv/publisher`) путь `media/<file>`
резолвится в `/srv/publisher/media/<file>` = `/opt/vesti/media/media/<file>`,
которого для новых постов не существует.
В publisher (`services/publisher/app/main.py`):
```python
if req.card.media and Path(req.card.media).exists():
```
проверка проваливается → медиа молча пропускается, публикуется только текст.
Ошибки нет — файл «просто не найден».
Проверено в контейнере:
```
docker exec vesti-publisher python -c "import os; print(os.path.exists('media/linux_education_2772.jpg'))" # False
```
Затронуты все посты с медиа, скачанные с 2026-09-12 (новая раскладка). Старых
файлов в `media/media/` на диске нет (545 постов со старым путём вообще без
файлов — отдельная проблема, не этого change).
## What Changes
1. **`services/publisher/docker-compose.yml`**: монтировать весь каталог медиа
`../../media:/srv/publisher/media:ro` вместо `../../media/media`. Тогда в
контейнере видны ОБЕ раскладки:
- новые файлы: `/srv/publisher/media/<file>` = `media/<file>` ✓
- старые (если появятся/восстановятся): `/srv/publisher/media/media/<file>` = `media/media/<file>` ✓
2. **`services/publisher/app/main.py`**: устойчивый резолвинг медиа-файла в
контейнере. Вместо единственной проверки `Path(card.media).exists()` —
пробовать кандидатов в порядке приоритета (под контейнерный CWD):
- `card.media` как есть (заданный путь, напр. `media/<file>`,
резолвится от `/srv/publisher`),
- `media/<basename>` (если путь в БД содержал подкаталог),
- `media/media/<basename>` (старая раскладка).
Первый существующий путь идёт в `send_media`.
Это чинит и будущие случаи, когда веб пришлёт нормализованный путь, и
старые посты, файлы которых восстановят.
3. **`publisher/card.py`** — не меняем (нормализация путей на стороне веба уже
работает, publisher получает `media='media/<file>'`).
## Why Not
- Не переносить файлы из `media/` в `media/media/` и не править 12 строк БД:
новая раскладка правильная, старую не размножаем.
- Не отключать проверку существования файла: у 545 старых постов файла нет,
publisher должен молча пропускать отсутствующее медиа, а не падать.
- Не менять `media_path` в БД: веб (route `/media/{filename}`, MEDIA_DIRS)
и читает оба каталога, и publisher после фикса тоже.
## Impact
- Файлы: `services/publisher/docker-compose.yml`, `services/publisher/app/main.py`.
- Сервис: пересоздать контейнер publisher (`docker compose up -d` — volume
меняется), проверить healthz.
- Данные: без миграций БД.
- Rollback: вернуть монтирование `../../media/media` и старый код, пересоздать
контейнер.
@@ -0,0 +1,9 @@
# fix-media-mount
- [x] OpenSpec change создан (proposal/design)
- [x] docker-compose.yml: монтировать `../../media:/srv/publisher/media` (весь каталог)
- [x] main.py: `_resolve_media()` — резолвинг media-пути в контейнере (media/, media/media/, basename)
- [x] GoToSocial `_publish_gotosocial` тоже использует `_resolve_media`
- [x] `docker compose up -d --build` — контейнер пересоздан, healthz ok
- [x] Проверка: `docker exec` видит `media/linux_education_2772.jpg`
- [x] e2e: publish текста+медиа в канал → media_message_id задан (13, 17); тестовые сообщения удалены (delete True)
@@ -0,0 +1,3 @@
schema: spec-driven
created: 2026-09-14
skip_specs: true
@@ -0,0 +1,44 @@
## Design
### 1. `web/templates/base.html` — отступ под fixed-top navbar
Текущее:
```css
body { padding-top: 1.5rem; background: #f5f6fa; }
```
Правка:
```css
body { padding-top: 4.5rem; background: #f5f6fa; }
```
Пояснение: Bootstrap `navbar fixed-top` имеет высоту по умолчанию 56px (3.5rem).
`padding-top: 4.5rem` = 72px → заголовок страницы больше не перекрывается меню.
Это правка глобальная — применится и к /candidates, и /published, и /metrics
(там отступ сейчас тоже недостаточен).
### 2. `web/templates/published.html` — ID поста на карточке
Текущее (строка 9):
```html
<span class="badge bg-secondary">{{ p.direction or '?' }}</span>
```
Правка:
```html
<span class="badge bg-secondary">#{{ p.id }} · {{ p.direction or '?' }}</span>
```
ID поста виден сразу на каждой карточке, рядом с направлением — как в списке
кандидатов.
## Верификация
- `openspec validate fix-published-page` — чисто.
- Проверка без перезапуска: `curl -s -b cookies http://127.0.0.1:8400/published`
→ в html:
- `padding-top: 4.5rem` присутствует в `<style>`;
- на карточках есть `#<id>` (например, `#136`, `#1013`).
- Рестарт `sudo systemctl restart vesti-web`, повторный curl — то же.
- Визуально: заголовок «Опубликованные посты» не перекрывается меню
(проверка в браузере).
@@ -0,0 +1,41 @@
## Why
На странице опубликованных постов (`/published`):
1. Заголовок «Опубликованные посты» перекрывается верхним меню. Причина:
`base.html` использует `navbar fixed-top` (position: fixed, поверх контента),
а `body` имеет только `padding-top: 1.5rem` (24px) — этого недостаточно:
Bootstrap fixed-top navbar высотой ~56px перекрывает первые ~32px контента.
Заголовок оказывается под меню.
2. На карточках постов нет ID — невозможно быстро сослаться на конкретный пост
(в интерфейсе кандидатов ID есть: `#153`, `#136` и т.д.). При обсуждении
«опубликуй/исправь пост такой-то» приходится искать по тексту.
## What Changes
- В `web/templates/base.html`:
- Увеличить `body { padding-top }` с `1.5rem` до `4.5rem` (~72px), чтобы
контент не заезжал под fixed-top navbar (56px + отступ).
- В `web/templates/published.html`:
- Добавить ID поста в карточку. Самый заметный вариант — в заголовок
рядом с направлением: `<span class="badge bg-secondary">#{{ p.id }} · {{ p.direction or '?' }}</span>`,
чтобы ID был виден сразу, как в кандидатах. Альтернатива — маленькая
подпись в шапке карточки.
## Why Not
- Не менять `fixed-top` на статичную навигацию: меню должно оставаться доступным
при скролле длинного списка опубликованных.
- Не добавлять ID отдельной строкой: визуальный шум; лучше в существующем
бейдже направления.
- Не трогать кандидатов (там ID уже есть).
## Impact
- Файлы: `web/templates/base.html`, `web/templates/published.html`.
- Данные: без миграций БД.
- Сервис: `vesti-web` (:8400) — перезапуск не требуется для Jinja2-шаблонов
(шаблоны читаются с диска при каждом рендере, `tpl` без кэша).
Тем не менее для надёжности — `sudo systemctl restart vesti-web`.
- Rollback: откатить правки шаблонов (git revert).
@@ -0,0 +1,10 @@
# fix-published-page
- [x] Создан OpenSpec change (proposal/design) — `skip_specs: true`
- [x] base.html: `body { padding-top: 4.5rem }` (отступ под fixed-top navbar)
- [x] published.html: на карточке ID поста `#{{ p.id }} · {{ p.direction }}`
- [x] `openspec validate fix-published-page` — чисто
- [x] Проверка: curl /published → padding-top 4.5rem + ID на карточках
- [x] Рестарт vesti-web
- [x] Обновить STATUS.md / TODO.md
- [x] Git push в gitverse
@@ -0,0 +1,3 @@
schema: spec-driven
created: 2026-09-16
skip_specs: true
@@ -0,0 +1,55 @@
# fix-sources-add-channel
## Дизайн
### 1. Форма добавления — поле channel (web/templates/sources.html)
Вставить после поля `url` (перед `crawler`), чтобы telegram-поля шли вместе:
```html
<div class="col-auto">
<label class="form-label mb-0 small">channel (telegram)</label>
<input type="text" name="channel" class="form-control form-control-sm"
placeholder="@handle или t.me/..." id="add_channel">
</div>
```
Поле обязательное только для telegram: маленький inline-скрипт в конце формы
переключает атрибут `required` при смене `select[name=crawler]`:
```html
<script>
const addCrawler = document.querySelector('form[action="/sources/add"] select[name="crawler"]');
const addChannel = document.getElementById('add_channel');
function toggleChannelRequired() {
addChannel.required = addCrawler.value === 'telegram';
}
addCrawler.addEventListener('change', toggleChannelRequired);
toggleChannelRequired();
</script>
```
### 2. Таблица — показ channel у telegram (web/templates/sources.html)
В колонке crawler (строки 91-93) добавить вывод channel для telegram,
по аналогии с feed_url для rss:
```html
<td><span class="badge text-bg-secondary">{{ s['crawler'] }}</span>
{% if s['crawler'] == 'rss' and s['feed_url'] %}<br><small class="text-muted">{{ s['feed_url'] }}</small>{% endif %}
{% if s['crawler'] == 'telegram' and s['channel'] %}<br><small class="text-muted">{{ s['channel'] }}</small>{% endif %}
</td>
```
### 3. Backend — без изменений
`web/app.py::_form_source` уже собирает `channel` (строка 877),
`sources/sources.py::_validate_source` уже проверяет (строка 218),
`db_source_to_yaml` уже пишет в yaml (строка 181). Правки только в шаблоне.
## Проверка
1. `openspec validate fix-sources-add-channel` — чисто.
2. Ручной тест через веб: добавить telegram-источник с channel → success;
без channel → браузер подсветит поле, серверная ошибка не нужна.
3. Рестарт `vesti-web`, проверить страницу /sources.
@@ -0,0 +1,34 @@
# fix-sources-add-channel
Добавление telegram-источника из веб-модерации падало с ошибкой
«telegram-источнику нужен channel», хотя пользователь заполнял slug и название.
## Why
В форме добавления источника (`web/templates/sources.html`) поле `channel`
отсутствовало. `_form_source()` в `web/app.py` читает `channel` из формы,
`_validate_source()` в `sources/sources.py:218` требует его для `crawler=telegram`
— поэтому любой telegram-источник, добавленный через веб, отклонялся,
а rss-источник — нет (feed_url в форме есть).
## What Changes
- **web/templates/sources.html (форма добавления)**: добавлено поле `channel`
с подсказкой «@handle или t.me/... для telegram»; для `crawler=telegram`
становится `required` (маленький inline-скрипт переключает).
- **web/templates/sources.html (таблица)**: у telegram-источников под crawler
показывается `channel` (аналогично feed_url у rss), чтобы было видно,
какой канал привязан.
## Why Not
- Не трогаем backend: `_form_source`, `_validate_source`, `add_or_update_source`
уже корректно работают с полем `channel` — не хватало только UI.
- Не делаем авто-подстановку channel из url — разные форматы
(@handle, t.me/name, https://t.me/s/name), подстановка ненадёжна.
## Impact
- Telegram-источники снова можно добавлять из веб-модерации.
- В таблице виден привязанный канал — легче отлаживать нерабочие источники.
- Никаких изменений БД и БД-миграций — только шаблон.
@@ -0,0 +1,15 @@
# fix-sources-add-channel
## Problem
Форма добавления источника не имеет поля `channel`, хотя валидатор
(`_validate_source`) требует его для telegram-источников → добавление
telegram-источника из веб падает с «telegram-источнику нужен channel».
## Tasks
- [x] sources.html (add-форма): поле `channel` после url, required для telegram
- [x] sources.html (таблица): показ channel у telegram-источников
- [x] openspec validate clean
- [x] рестарт vesti-web; ручная проверка /sources (добавление telegram-источника)
- [x] STATUS.md актуализирован; commit + push gitverse
@@ -0,0 +1,3 @@
schema: spec-driven
created: 2026-09-14
skip_specs: true
@@ -0,0 +1,89 @@
## Design
### 1. `_fetch_candidates` — статус по умолчанию vs явный
Текущее (строки ~151-166):
```python
where.append("p.is_own=0")
where.append("p.status='new'")
...
if status and status != "new":
where.append("p.status=?")
params.append(status)
```
Правка:
```python
where.append("p.is_own=0")
if not status:
# по умолчанию — только кандидаты (new); явный статус ниже
where.append("p.status='new'")
else:
# явный статус (new/rejected/published) — единственный фильтр статуса
where.append("p.status=?")
params.append(status)
if direction:
where.append("p.direction=?")
params.append(direction)
if own in ("1", "0"):
where.append("p.is_own=?")
params.append(int(own))
if q:
where.append("(p.text LIKE ? OR p.summary LIKE ?)")
params += [f"%{q}%", f"%{q}%"]
```
### 2. Скрытые поля в формах правой панели
В `candidates.html` в каждую форму правой панели (approve, reclassify, rewrite,
reject, comment) добавить:
```html
<input type="hidden" name="group_by" value="{{ group_by }}">
<input type="hidden" name="direction" value="{{ direction }}">
<input type="hidden" name="own" value="{{ own }}">
<input type="hidden" name="q" value="{{ q }}">
```
(Переменные `group_by`, `direction`, `own`, `q` уже доступны в контексте рендера
роута `/candidates`.)
### 3. Роуты POST-действий — сохранять контекст
Общий хелпер для построения URL возврата:
```python
def _cand_back(group_by="", direction="", own="", q="", **extra):
parts = []
if group_by:
parts.append(f"group_by={group_by}")
if direction:
parts.append(f"direction={direction}")
if own in ("0", "1"):
parts.append(f"own={own}")
if q:
parts.append(f"q={quote(q)}")
for k, v in extra.items():
if v:
parts.append(f"{k}={v}")
return "/candidates?" + "&".join(parts) if parts else "/candidates"
```
Применение:
- `reject(post_id, request, group_by="", direction="", own="", q="")`:
`return RedirectResponse(url=_cand_back(group_by, direction, own, q, status="rejected"), status_code=302)`
→ пользователь остаётся в своей группировке, видит список отклонённых.
- `comment`/`reclassify`/`rewrite`: `_cand_back(group_by, direction, own, q, selected=post_id)`.
- `approve`: после успеха — `_cand_back(group_by, direction, own, q, status="published")`
(пользователь видит опубликованные в той же группировке) или `/published`.
Решение: approve возвращает в кандидаты с `status=published` в той же группировке,
чтобы контекст не терялся. Ошибки — `_cand_back(..., selected=post_id, error=err)`.
FastAPI: параметры форм объявляются как `group_by: str = Form("")` и т.д.
## Верификация
- `openspec validate keep-candidates-context` — чисто.
- Юнит: `_fetch_candidates(c,'','rejected','','','date')` → возвращает
отклонённые (не пусто); `_fetch_candidates(c,'','','','','date')` → new внешние.
- Живой: POST /posts/{id}/reject с form group_by=date → редирект на
`/candidates?group_by=date&status=rejected`, список не пуст.
- Рестарт vesti-web.
@@ -0,0 +1,48 @@
## Why
Два бага при работе со списком кандидатов:
1. **Пустой список при `?status=rejected`.** После фикса
`candidates-only-external-new` в `_fetch_candidates` жёстко добавлено
`p.status='new'`. Когда пользователь явно выбирает `status=rejected`
(или `published`), в WHERE попадают ОБА условия: `p.status='new' AND
p.status='rejected'` → выборка всегда пустая, страница показывает
«Нет кандидатов по фильтру».
2. **Сброс группировки после действия.** Роут `/posts/{id}/reject`
редиректит на `/candidates?status=rejected` без сохранения `group_by`
(и direction/own/q). Пользователь был в группировке «дата» → после
отклонения его выбрасывает на `?status=rejected` с дефолтной группировкой
«источник». Аналогично approve/reclassify/rewrite/comment редиректят на
`?selected={id}` без сохранения группировки.
## What Changes
1. **`web/app.py`, `_fetch_candidates`:** жёсткое `p.status='new'` добавлять
только когда параметр `status` пуст (значение по умолчанию = кандидаты
new). Если `status` задан явно (`new`/`rejected`/`published`) — применять
его как единственный фильтр статуса. `p.is_own=0` остаётся всегда
(свои посты не кандидаты).
2. **Формы правой панели в `candidates.html`** (approve, reclassify, rewrite,
reject, comment): добавить скрытые поля `group_by`, `direction`, `own`, `q`
со значениями текущей страницы.
3. **Роуты POST-действий** (approve, reclassify, rewrite, reject, comment):
читать `group_by`/`direction`/`own`/`q` из формы и строить редирект с этими
параметрами, чтобы пользователь остался в той же группировке/фильтре.
## Why Not
- Не парсить `Referer`: хрупко и небезопасно.
- Не возвращать на `/published` после approve без сохранения контекста:
пользователь работает в списке кандидатов и хочет остаться в нём.
- Не менять SQL-структуру counts: counts считаются по внешним постам и уже
корректны.
## Impact
- Файлы: `web/app.py`, `web/templates/candidates.html`.
- Данные: без миграций БД.
- Сервис: `vesti-web` (:8400) — перезапуск.
- Rollback: откатить правки, перезапустить.
@@ -0,0 +1,12 @@
# keep-candidates-context
- [x] Создан OpenSpec change (proposal/design) — skip_specs: true
- [x] _fetch_candidates: жёсткое status='new' только при пустом status; явный статус — единственный фильтр
- [x] candidates.html: скрытые поля group_by/direction/own/q в формах approve/reclassify/rewrite/reject/comment
- [x] Роуты POST-действий: редирект сохраняет group_by и фильтры
- [x] openspec validate keep-candidates-context — чисто
- [x] Юнит: status=rejected → 4 поста (не пусто); по умолчанию → 163 new внешних
- [x] Живой: hidden-поля в формах; ?status=rejected → 4 поста, нет «Нет кандидатов»
- [x] Рестарт vesti-web
- [x] Обновить STATUS.md / TODO.md
- [x] Git push в gitverse
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-08
@@ -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 по каждому
@@ -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 для форвардов); интеграционная проверка после бэкфилла
- [x] 2.3 Бэкфилл своего канала: первый прогон ~1039 постов (медиа по возможности; при лимите — текст без медиа, бэкфилл-флаг) — СДЕЛАНО 2026-09-10 (backfill_dedinit.py, fetched=1040, max_post_id=1092, 846 в БД; ныне 848 is_own)
Проверка: `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. Проверка интеграции и документация
- [x] 7.1 Полный прогон: синк → краулер dedinit → классификатор → веб (approve с fan-out) → бандлы; внешние источники не затронуты (is_own=0 по умолчанию) — СДЕЛАНО (реальные approve 136/137, 2026-09-09; бэкфилл 2026-09-10)
Проверка: counts по is_own в БД, бандлы, /published, runs ok — ждёт бэкфилла 2.3 (реальная сеть)
- [x] 7.2 Обновить STATUS.md / TODO.md / WALKTHROUGH.md (что сделано, как запускать, питфолы)
Проверка: документы отражают новое состояние (обновлено при закрытии сессии)
@@ -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)
## Открытые пункты
- [x] publisher: медиа из card.media — путь в БД /opt/vesti/media/... не совпадает с монтированием в контейнере (/srv/publisher/media) → send_photo не уходит при Docker-запуске — ЗАКРЫТО change fix-media-mount (2026-09-14): контейнер монтирует весь каталог media/, publisher резолвит media/ и media/media/
@@ -0,0 +1,3 @@
schema: spec-driven
skip_specs: true
created: 2026-09-15
@@ -0,0 +1,57 @@
## Design
Файл: `/opt/vesti/web/app.py`.
### 1. `reject()` — строка 582
Сейчас:
```python
return RedirectResponse(url=_cand_back(group_by, direction, own, q, status="rejected"), status_code=302)
```
Стало:
```python
return RedirectResponse(url=_cand_back(group_by, direction, own, q), status_code=302)
```
`_cand_back` без `status` строит `/candidates` с сохранением
`group_by/direction/own/q` — очереди кандидатов (`status=new` по умолчанию).
### 2. `candidates_bulk()` — строки 345, 358
Сейчас:
```python
if action == "reject":
...
return RedirectResponse(url=back + "&status=rejected", status_code=302)
...
if action == "reject-old":
...
return RedirectResponse(url=back + "&status=rejected", status_code=302)
```
Стало: редирект на `back` (уже содержит `group_by` + фильтры), без
`&status=rejected`.
```python
if action == "reject":
...
return RedirectResponse(url=back, status_code=302)
...
if action == "reject-old":
...
return RedirectResponse(url=back, status_code=302)
```
## Верификация
- `openspec validate reject-stay-candidates` — чисто.
- Юнит (TestClient): login → POST `/posts/{id}/reject` с `group_by=date`
→ ожидаем `Location: /candidates?group_by=date` (БЕЗ `status=rejected`).
- Перезапуск: `sudo systemctl restart vesti-web`.
- Ручная проверка: отклонить пост из правой панели → остаёмся на
«Кандидатах» в той же группировке, пост пропал из списка; список
отклонённых доступен через фильтр «🗑 Отклонённые».
@@ -0,0 +1,37 @@
## Why
После нажатия «🚫 Отклонить» в правой панели кандидата пользователь
попадает на страницу `/candidates?status=rejected` (отдельный список
отклонённых), а не остаётся в текущем списке кандидатов. Это разрывает
поток отбора: после отклонения одного поста нужно вернуться в очередь
кандидатов и продолжить.
Same для массового отклонения через bulk-форму (левая панель,
«🚫 Отклонить» / «🗑 Старьё»): редирект уводит на `status=rejected`.
## What Changes
- `web/app.py`, `reject()` (`POST /posts/{id}/reject`): редирект после
отклонения — на `/candidates` с сохранением текущей группировки и
фильтров, БЕЗ `status=rejected` (чтобы остаться в очереди кандидатов).
Т.е. `_cand_back(group_by, direction, own, q)` без `status`.
- `web/app.py`, `candidates_bulk()`: для `action == "reject"` и
`action == "reject-old"` — редирект на `back` (текущая группировка +
фильтры) БЕЗ `&status=rejected`.
Текущее поведение (reject → список отклонённых) доступно вручную через
фильтр статуса «🗑 Отклонённые» или `?status=rejected`.
## Why Not
- Не менять approve: после публикации переход на опубликованные —
осознанное поведение (посмотреть результат), менять не просили.
- Не добавлять параметр «куда вернуться»: редирект на текущую группировку
достаточен и предсказуем.
## Impact
- Файл: `web/app.py` (две точки редиректа).
- Данные: без изменений.
- Сервис: `vesti-web` (:8400) — перезапуск.
- Rollback: вернуть `status="rejected"` в редиректах и перезапустить.
@@ -0,0 +1,11 @@
# reject-stay-candidates
- [x] Создан OpenSpec change (proposal/design) — `skip_specs: true`
- [x] `openspec validate reject-stay-candidates` — чисто
- [x] web/app.py `reject()`: редирект на `/candidates` без `status=rejected`
- [x] web/app.py `candidates_bulk()`: reject/reject-old → `back` без `status=rejected`
- [x] Тест (TestClient): Location после reject содержит group_by, не содержит `status=rejected`
- [x] Перезапуск `sudo systemctl restart vesti-web`
- [x] Ручная проверка: после отклонения остаёмся на кандидатах (живой URL `/candidates?group_by=date`)
- [x] Обновить STATUS.md / TODO.md
- [x] Бэкап (крон 2:45 делает сам, вручную НЕ запускать)
@@ -0,0 +1,3 @@
schema: spec-driven
skip_specs: true
created: 2026-09-15
@@ -0,0 +1,58 @@
# rss-formatting-preserve: сохранять форматирование RSS-новостей (абзацы, разметка)
## Why
Пользователь: в RSS omarchy внутри текста новости есть HTML-символы, а мы их «выкидываем»
из текста, из-за чего вся новость в предпросмотре отображается сплошным текстом, ничего
не понять. Нужно при скачивании из источников RSS сохранять заложенное форматирование.
Разбор:
- `html_to_text()` (crawler/rss_crawler.py) режет HTML корректно, но без разделителей
между блочными элементами (p/li/h1-6/blockquote) — абзацы/списки сливаются в один кусок.
- После этого `re.sub(r"\s+", " ", text)` (стр. 143) схлопывает и переносы строк —
даже если бы были `\n\n`, они превращаются в один пробел. Итог: «сплошной текст».
- В вебе `md_filter` (web/app.py) вызывается на `text` из RSS — синтаксис HTML (b/i/a)
экранируется и виден как текст-каша, а markdown-разметки в RSS-тексте нет.
## Design
### 1. Краулер: сохранять структуру абзацев
`html_to_text()`:
- после удаления script/style/noscript — вставлять переносы-разделители вокруг блочных
тегов (p, div, li, h1–h6, blockquote, pre, tr), чтобы они давали `\n\n` между абзацами
и `\n` внутри list-item.
- текст по-прежнему plain (в БД хранится текст, а не HTML) — просто читаемая структура.
`parse_entries()` (стр. 143): заменить `re.sub(r"\s+", " ", text)` на нормализацию:
- схлопывать 3+ переноса до 2 (максимум пустая строка между абзацами);
- убирать горизонтальные пробелы в начале/конце строк и перед переносами;
- пробелы внутри строки НЕ трогать (сохраняются слова/URL).
### 2. Веб: безопасный рендер HTML-разметки из RSS
Исходный текст поста (RSS) в предпросмотре (кандидаты, отобранные) — рендерить как
безопасный HTML подмножеством тегов, а не экранировать в тёмную кашу:
- новый фильтр `safe_html` в web/app.py на основе `bleach`:
`bleach.clean(text, tags=ALLOWED, attributes={'a': ['href', 'title']},
protocols=['http','https'])` + `bleach.linkify`;
- применяется ТОЛЬКО к `text` (оригинал из RSS) в candidates.html и selected.html;
- markdown-фильтр (`md_filter`) остаётся для пересказов/комментариев (markdown от модели).
Публикация (publisher/card.py) не меняется: в канал уходит plain text, абзацы `\n\n`
будут сохранены — Telegram корректно отобразит переносы.
### Безопасность
- `safe_html` — allowlist-санитайзер (bleach): никаких script/style/iframe/on*, href
только http(s), linkify распознаёт ссылки. XSS-поверхности нет.
- `md_filter` не меняется (остаётся escape→markdown).
## Files
- crawler/rss_crawler.py — html_to_text(), parse_entries() (абзацы, нормализация).
- web/app.py — фильтр safe_html (bleach).
- web/templates/candidates.html — `{{ selected.text | safe_html }}`.
- web/templates/selected.html — `{{ selected.text | safe_html }}`.
- requirements.txt — +bleach.
@@ -0,0 +1,27 @@
# rss-formatting-preserve
Сохранять форматирование RSS-новостей (абзацы, разметка) при скачивании и в предпросмотре.
## Why
Пользователь: в RSS omarchy внутри текста новости есть HTML-символы, мы их выкидываем
из текста, поэтому вся новость в предпросмотре — сплошной текст, ничего не понять.
Нужно при скачивании из RSS сохранять заложенное форматирование.
## What Changes
- **crawler/rss_crawler.py**: html_to_text() вставляет переносы вокруг блочных тегов
(p/li/h1-6/blockquote/pre), чтобы абзацы и списки сохранялись; parse_entries() больше
не схлопывает все пробелы в один — нормализует: 3+ переноса → 2, пробелы по краям
строк убираются, внутренние сохраняются.
- **web/app.py**: новый фильтр `safe_html` (bleach.clean allowlist + linkify) для
безопасного рендера HTML-разметки исходных RSS-текстов в предпросмотре.
- **web/templates/candidates.html / selected.html**: `| safe_html` для `selected.text`.
- **requirements.txt**: +bleach.
## Impact
- Тексты в БД: появляются абзацы (\n\n), форматирование сохраняется читаемым.
- Предпросмотр кандидатов/отобранных: разметка (b/i/a/списки) рендерится безопасно.
- Markdown-пересказы (rewritten_text) и комментарии не задеваются — md_filter без изменений.
- Публикация (card.py) — plain text, абзацы \n\n дойдут в Telegram.
@@ -0,0 +1,18 @@
# rss-formatting-preserve
## Problem
Тексты из RSS (omarchy и др.) в предпросмотре — сплошной текст: абзацы/списки/разметка
теряются. При скачивании нужно сохранять заложенное форматирование.
## Tasks
- [x] rss_crawler.py: html_to_text() — разделители между блочными тегами (p/li/h1-6/blockquote/pre)
- [x] rss_crawler.py: parse_entries() — не схлопывать все пробелы `\s+`; normalize_text (3+ переноса → 2, пробелы по краям строк убрать, внутренние сохранить)
- [x] rss_crawler.py: extract_full_text() — include_formatting=True (trafilatura) для сохранения абзацев
- [x] web/app.py: фильтр `safe_html` (bleach.clean allowlist + linkify); md_filter не тронут
- [x] web/templates/candidates.html: `selected.text | safe_html` вместо `| markdown`
- [x] web/templates/selected.html: `selected.text | safe_html` вместо `| markdown`
- [x] requirements.txt: +bleach (установлен в venv)
- [x] Пересборка старых RSS-постов: 47 обновлено с абзацами (omarchy 25/25, bleepingcomputer 16/16; lwn 12 — 403/429, будут при следующем крауле)
- [x] STATUS.md актуализирован; validate; commit
@@ -0,0 +1,3 @@
schema: spec-driven
skip_specs: true
created: 2026-09-15
@@ -0,0 +1,31 @@
# selected-list
## Design
### БД
- Статус `selected` — новое значение `posts.status`, миграция не нужна (status — TEXT без CHECK).
- Колонка `is_read` уже есть. Новых колонок нет.
### Маршруты веб (`web/app.py`)
- `GET /candidates` — без изменений (кандидаты = new). Из шаблона убираем approve/comment/reject/reclassify/rewrite кнопки, добавляем «⭐ Отобрать».
- `POST /candidates/select` — теперь «отобрать»: `UPDATE posts SET status='selected' WHERE id=? AND is_own=0 AND status='new'`. Удалить из старого черновика прежний смысл (выбор поста для просмотра) — он больше для «выбора карточки» не нужен (клик по карточке — GET ссылка, выборка кнопкой «Отобрать»).
- `GET /selected` — главный новый: список отобранных (status=selected, is_own=0), двухпанельный как /candidates, но с полным функционалом. Копия логики /candidates с параметром status='selected'.
- `POST /selected/select` — выбор карточки в отобранных (аналог старого candidates/select, но GET-переход по ссылке, form не нужна).
- `POST /posts/{id}/reject` — уже есть, редиректит на /candidates; добавить `back` с параметром (чтобы из selected вести обратно в selected).
- `impl_approve` — уже публикует rewritten_text если он есть; ok.
- Bulk в selected: action=select (для кандидатов), approve/reject/process/reject-old — там же, где в candidates.
### Шаблоны
- `candidates.html` — правый блок: только статус/бейджи + «⭐ Отобрать» (single) + bulk. Убрать approve-form/comment/reclassify/rewrite/reject.
- `selected.html` — НОВЫЙ (клон candidates.html) с полным функционалом + поле `rewritten_text` ВСЕГДА рядом с оригиналом + кнопки.
- `base.html` — навбар: ссылка «Отобранные» (со счётчиком) между «Кандидаты» и «Опубликовано».
### Открытые решения
- GET /candidates рендерит и левую панель; в кандидатах больше нет «выбора для просмотра» —
клик по карточке открывает пост (selected) — оставим клик по ссылке на правосторонний
просмотр (тот же механизм `?selected=`), но сам статус «отобрать» — отдельной кнопкой.
- В /selected — «🚫 Отклонить» отобранного → status=rejected, редирект на /selected.
@@ -0,0 +1,44 @@
# selected-list: список отобранных между кандидатами и опубликованными
## Улучшение
Сейчас пайплайн «кандидаты → опубликованные» без промежуточного шага:
- Кандидаты (new): куча новых постов, классификация всех через LLM (токены), обработка
(comment/approve/reject/reclassify/rewrite) прямо здесь — всё сразу.
- Пользователь: «репост никому не интересен, подпишутся на первоисточник» — нужен
собственный подготовленный пост. Отбираем понравившееся руками, готовим свой пост
уже в отобранных.
## Изменения
### Новый статус `selected`
- `posts.status`: добавляем значение `selected` («отобранный»).
- Кандидаты = `status='new'` (внешние). Отобранные = `status='selected'` (внешние).
- Свой контент (is_own=1) — не кандидаты и не отобранные (управляется отдельно).
### Веб
- **Кандидаты (`/candidates`)**: только «⭐ Отобрать» (single + bulk `select`).
БЕЗ approve/reject/comment/reclassify/rewrite — обработка живёт в отобранных.
Кнопки «Обработать моделью/Переписать/Отклонить/Опубликовать/комментарий» — убираем.
- **Отобранные (`/selected`)**: новый экран, полный функционал:
- карточки слева (как кандидаты), статус selected
- справа: бейджи, комментарий, «✅ Опубликовать», «💾 Сохранить комментарий»,
«🤖 Обработать моделью» (reclassify), «✍️ Переписать» (rewrite),
«💾 Сохранить пересказ», «🚫 Отклонить»
- bulk: approve / reject / process / select
- `published` — без изменений.
### Редактор всегда виден
В отобранных рядом с оригиналом поста — поле редактирования (textarea `rewritten_text`):
- показывается ВСЕГДА (не только если уже есть пересказ)
- в него кладётся результат «✍️ Переписать» (rewrite)
- редактируется вручную
- при публикации (approve) — body = rewritten_text если есть, иначе оригинал (уже так в card.py)
### Экономия токенов
- Краулер НЕ классифицирует новые (сейчас и так worker не в кроне; classified остаётся 0).
- Классификация — только по кнопке «🤖 Обработать моделью» В ОТОБРАННЫХ (reclassify на selected).
@@ -0,0 +1,14 @@
# selected-list
- [x] OpenSpec change создан (proposal/design) — skip_specs
- [x] openspec validate selected-list
- [x] web/app.py: GET /selected (двухпанельный, status=selected) + выбор поста (переклики с candidates)
- [x] web/app.py: POST /posts/{id}/select (new → selected) + bulk action=select
- [x] web/app.py: _cand_back(+src), все обработчики (reject/comment/reclassify/rewrite/rewrite-save/approve/bulk) — учитывают src
- [x] web/templates/selected.html: список + полный функционал + редактор «Мой пост» рядом с оригиналом
- [x] web/templates/candidates.html: только «⭐ Отобрать» (single + bulk) — убраны approve/reject/process/rewrite/comment/редактор
- [x] web/templates/base.html: навбар «⭐ Отобранные»
- [x] bulk rewrite (переписать выбранные)
- [x] Тесты: select single/bulk → /selected; кнопки; rewrite (реальный Ollama) → rewritten_text; живой HTTP
- [x] Откат тестовых мутаций (85/109 → new)
- [x] Обновить STATUS.md + коммит/пуш