mirror of
https://gitverse.ru/kpa39l/vesti.git
synced 2026-09-29 09:55:03 +00:00
openspec: архив 14 завершённых change-ов (веб-фиксы, crawler-queue, own-content-hub, publisher-service); спеки влиты в openspec/specs
This commit is contained in:
@@ -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) — правило окружения.
|
||||
+79
@@ -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 + коммит/пуш
|
||||
Reference in New Issue
Block a user