mirror of
https://gitverse.ru/kpa39l/vesti.git
synced 2026-09-29 18:05:03 +00:00
Compare commits
41 Commits
c3f59f7b7a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c8ccd7e187 | |||
| a10ab9fa3c | |||
| 56610fa836 | |||
| 6a1c894ff7 | |||
| 1ae4b14d24 | |||
| 0d4ecd1c23 | |||
| 48b10349ec | |||
| 64baa7ea73 | |||
| 4a166fa9d6 | |||
| 745680a3a0 | |||
| c8fe5f7c85 | |||
| 8777a0c4a4 | |||
| 48c25f13d4 | |||
| 87134ed839 | |||
| 771f6a8276 | |||
| 584582a48c | |||
| 8592a82381 | |||
| 582dc3cf66 | |||
| b25054e1df | |||
| 8d26c97e6b | |||
| 51a13de28c | |||
| ae8af8de9c | |||
| b542e9b4fa | |||
| c09dff08ac | |||
| 562d787f1b | |||
| ee2f8ac61a | |||
| a6cf605f52 | |||
| e9f7c96e84 | |||
| 0999424baa | |||
| acb49cc702 | |||
| e2bdb4a87b | |||
| f890ad71f1 | |||
| c66eded7bf | |||
| 0e487f672a | |||
| 60617de0cb | |||
| fc6d725bd1 | |||
| 970bf14a9d | |||
| b2af4bb7c8 | |||
| 6b955aa143 | |||
| bf176cc02b | |||
| 71fd48aa06 |
+16
-1
@@ -10,9 +10,24 @@ TG_SESSION_DIR=/opt/vesti/telegram
|
||||
VESTI_BOT_TOKEN=your_bot_token_here
|
||||
# Канал публикации (шаблон dedinit_vesti_<direction>_<lang>_bot)
|
||||
VESTI_BOT_CHANNEL=@dedinit_vesti_linux_ru_bot
|
||||
# GoToSocial (fediverse, Mastodon-совместимый API) — публикация от имени kpa39l
|
||||
# Нода: https://social.dedinit.ru (bigbox, Docker-контейнер gotosocial, :8082)
|
||||
GT_SOCIAL_URL=https://social.dedinit.ru
|
||||
# Bearer-токен пользователя kpa39l (OAuth, scope write; см. change gotosocial-publisher)
|
||||
GT_SOCIAL_ACCESS_TOKEN=your_gotosocial_token_here
|
||||
# Видимость статуса: public | unlisted | private
|
||||
GT_SOCIAL_VISIBILITY=public
|
||||
# Формат статуса: markdown (GtS поддерживает) | plain
|
||||
GT_SOCIAL_FORMAT=markdown
|
||||
# Fediverse-каналы (отдельно от Telegram-каналов; через запятую)
|
||||
VESTI_GTS_CHANNELS=@kpa39l@dedinit.ru
|
||||
# Веб-админ
|
||||
ADMIN_USER=admin
|
||||
ADMIN_PASSWORD=change_me
|
||||
# Ollama
|
||||
OLLAMA_URL=http://127.0.0.1:11434
|
||||
OLLAMA_MODEL=qwen3:8b-nothink
|
||||
OLLAMA_MODEL=qwen3:8b-nothink
|
||||
# RSS-краулер VESTI (честный User-Agent, обязателен для публичных лент)
|
||||
RSS_USER_AGENT=vesti-rss/0.1 (+https://vesti.nixg.ru)
|
||||
# Таймаут HTTP-запроса к ленте, сек
|
||||
RSS_DEFAULT_TIMEOUT=20
|
||||
@@ -1,6 +1,7 @@
|
||||
# AGENT.MD — Правила проекта VESTI
|
||||
|
||||
## Правило №1: ВСЕ изменения — через OpenSpec
|
||||
- **Перед каждым планируемым изменением — обязательно grill-with-docs** (интервью по дизайн-дереву: глоссарий в CONTEXT.md + ADR при необходимости). Только после достижения общего понимания с пользователем — создавать change.
|
||||
- **Любое изменение проекта (багфикс, фича, рефакторинг, конфиг, деплой, docs) оформляется как отдельный change** в `/opt/vesti/openspec/changes/<change-name>/`.
|
||||
- **НЕ начинать правки кода/файлов, пока change не создан и не провалидирован (`openspec validate` чисто).** Это обязательное требование (у VESTI нет бэкапа — OpenSpec фиксирует состояние и откат).
|
||||
- **Сразу после правок — обновить STATUS.md/TODO.md и сделать бэкап** (см. «Бэкап» ниже).
|
||||
@@ -16,6 +17,7 @@
|
||||
|
||||
## Стек и архитектура
|
||||
- Новостной агрегатор: TG-краулер → классификатор (Ollama qwen3:8b-nothink) → веб-модерация → публикация в Telegram.
|
||||
- Веб: :8400 — страницы /candidates (модерация), /crawlers (статус краулеров, запуск воркера), /sources (управление источниками: add/edit/toggle-pause/delete; источник истины — sources.yaml).
|
||||
- `vesti-web` (FastAPI+Jinja2, :8400, systemd-юнит) --HTTP POST--> `publisher-service` (FastAPI, Docker-контейнер, :8410) --Bot API (SOCKS5 127.0.0.1:1080)--> Telegram @dedinit_vesti.
|
||||
- БД: SQLite `/opt/vesti/db/vesti.db` — открывать с WAL + busy_timeout (иначе `database is locked` при фоновом классификаторе).
|
||||
- Фронтенд: **без внешних CDN** (никаких unpkg/jsdelivr). Bootstrap локален: `web/static/bootstrap.min.css`, htmx запрещён. approve/reject — обычные POST-формы.
|
||||
@@ -43,6 +45,11 @@ sudo systemctl restart vesti-web # веб :8400 (0.0.0.0, uvicorn web.a
|
||||
docker compose -f services/publisher/docker-compose.yml up -d --build # publisher :8410
|
||||
curl -s http://127.0.0.1:8410/healthz # ok, bot=dedinit_controller_bot, proxy, channels
|
||||
.venv/bin/python -m crawler.telegram_crawler --all # краулер (инкрементально)
|
||||
.venv/bin/python -m crawler.rss_crawler --all # RSS/Atom-краулер (условные GET, etag/modified; rss_state)
|
||||
.venv/bin/python -m crawler.rss_crawler --source lwn --dry-run # сухой прогон без записи в БД
|
||||
.venv/bin/python -m crawler.crawl_sources --all # фаза 1: сбор кандидатов в очередь (telegram+rss)
|
||||
.venv/bin/python -m crawler.worker --workers 8 --limit 200 # фаза 3: параллельная обработка очереди (ThreadPoolExecutor)
|
||||
MAX_CONCURRENT_LLM=2 .venv/bin/python -m crawler.worker --workers 8 # лимит одновременных LLM-запросов к Ollama
|
||||
CLASSIFY_TIMEOUT=20 .venv/bin/python -m classifier.classify --db db/vesti.db --limit 200
|
||||
openspec validate <change-name> # валидация change
|
||||
```
|
||||
@@ -51,9 +58,8 @@ openspec validate <change-name> # валидация change
|
||||
## Направления (directions)
|
||||
linux, tech, politics, games, electronics, llm. Свой канал @dedinit — источник `own: true`, посты is_own=1, fan-out по направлениям с атрибуцией «Дед в АйТи».
|
||||
|
||||
## Бэкап (ОБЯЗАТЕЛЬНО, у VESTI нет собственного бэкапа)
|
||||
## Бэкап (автоматический, вручную НЕ запускать)
|
||||
- Скрипт: `/opt/vesti/backup.sh` (по образцу `/opt/icq/backup.sh`): архивирует проект → `/opt/vesti/backups/vesti_<дата>.tar.gz`, копирует на Яндекс.Диск `/mnt/yandex-disk/backup/vesti-backups/`.
|
||||
- Хранение: локально 7 дней, на ЯД 30 дней (ротация в скрипте).
|
||||
- Запуск: root cron `45 2 * * *` (как у icq). ЯД монтируется автоматически (fstab davfs + @reboot).
|
||||
- **После ЛЮБЫХ изменений проекта** (или по требованию) — запустить `sudo /opt/vesti/backup.sh` и проверить, что архив появился в `/mnt/yandex-disk/backup/vesti-backups/`.
|
||||
- Проверка: `ls -lh /opt/vesti/backups/ | tail -3` и `ls -lh /mnt/yandex-disk/backup/vesti-backups/ | tail -3`.
|
||||
- Бэкапы запускаются автоматически по крону — вручную НЕ запускать.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Ctrl-C: VESTI — новостной конвейер
|
||||
|
||||
## Что это
|
||||
|
||||
Конвейер новостей: краулер источников → классификатор (LLM) → банк статей →
|
||||
веб-интерфейс отбора (модерация) → публикация в Telegram (@dedinit_vesti)
|
||||
и Fediverse (GoToSocial @vesti@dedinit.ru).
|
||||
|
||||
## Стек
|
||||
|
||||
FastAPI + Jinja2 (vesti-web, :8400), publisher-service (Docker, :8410),
|
||||
SQLite db/vesti.db, локальная LLM — Ollama qwen3:8b-nothink.
|
||||
Фронтенд — локальный Bootstrap 5.3 (без CDN), без htmx/JS-фреймворков.
|
||||
|
||||
## Правила
|
||||
|
||||
- **ВСЕ изменения — через OpenSpec** (change в openspec/changes/), перед каждым
|
||||
планируемым изменением — **grill-with-docs** (обязательно, AGENT.MD).
|
||||
- НЕ начинать правки кода, пока change не создан и `openspec validate` не чисто.
|
||||
- После правок — STATUS.md/TODO.md + бэкап (автокрон, вручную не запускать).
|
||||
- Дата в UI — фильтр `| dt` (ЧЧ:ММ ДД.ММ.ГГГГ), markdown — XSS-safe фильтр.
|
||||
- Секреты — только в .env, не коммитить; .env.example актуализировать.
|
||||
- Ссылки на ресурсы и доступы — фиксировать в файлах проекта.
|
||||
|
||||
## Запуск / проверка
|
||||
|
||||
```bash
|
||||
cd /opt/vesti
|
||||
sudo systemctl restart vesti-web # веб :8400
|
||||
docker compose -f services/publisher/docker-compose.yml up -d --build # publisher :8410
|
||||
curl -s http://127.0.0.1:8410/healthz
|
||||
openspec validate <change-name>
|
||||
```
|
||||
|
||||
Внешний доступ: https://vesti.nixg.ru (Caddy на VPS02, reverse_proxy 10.8.0.2:8400).
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
# CONTEXT.md — Глоссарий домена VESTI
|
||||
|
||||
Запись терминов по мере их уточнения в дизайн-сессиях. Только глоссарий:
|
||||
никаких деталей реализации, скетчей спецификаций или ADR.
|
||||
|
||||
## Термины
|
||||
|
||||
### Архив
|
||||
Страница опубликованных постов на сайте, организованная по времени публикации.
|
||||
Представляет собой **не** плоский список, а **дерево навигации**: годы → месяцы.
|
||||
|
||||
### Дерево навигации архива
|
||||
Навигационный элемент (правая колонка страницы архива), перечисляющий годы и,
|
||||
внутри каждого года, месяцы. Строится **только из фактически существующих**
|
||||
периодов (постов): если в периоде нет постов — он не появляется в дереве.
|
||||
Клик по месяцу показывает посты за этот месяц, клик по году — все посты за год.
|
||||
|
||||
### Период без даты
|
||||
Группа постов, у которых не заполнена дата публикации (`published_at`).
|
||||
Показывается в дереве навигации отдельной группой «Без даты».
|
||||
|
||||
### Текущий период (активный)
|
||||
Период (год/месяц или «все посты»), выбранный сейчас в дереве навигации;
|
||||
подсвечивается, чтобы модератор видел, где он находится.
|
||||
@@ -1,15 +1,67 @@
|
||||
# VESTI — Статус
|
||||
|
||||
Обновлено: 2026-09-13 (publisher снова на Docker; systemd1 ожил)
|
||||
Обновлено: 2026-09-20 (активны: post-translate, archive-navigation, extract-post-sources-остаток, rich-repost-card-аудит)
|
||||
<!--
|
||||
История обновлений:
|
||||
2026-09-13 — publisher: Docker-контейнер (основной способ); user-юнит disabled (костыль убран)
|
||||
2026-09-20 — Приняты в контекст незакоммиченные наработки (проверена реализация):
|
||||
extract-post-sources (backend: sources/extract.py, _post_sources, POST /sources/add-from-post),
|
||||
manual-direction (impl_reclassify пишет manual_direction без LLM, select в selected.html),
|
||||
crawler-очередь (crawler/worker.py ThreadPoolExecutor, crawl_sources.py, /crawlers + crawlers.html).
|
||||
Код в HEAD (кроме untracked: worker.py, crawl_sources.py, extract.py, crawlers.html, CONTEXT.md, docs/).
|
||||
Не коммитил (по указанию пользователя). Добавлены задачи: выполненные и планируемые.
|
||||
2026-09-18 — post-translate: кнопка «🌐 Перевести» в карточках кандидатов и отобранных.
|
||||
Перевод на русский локальной моделью (qwen3:8b-nothink), результат в
|
||||
posts.translated_text (колонка добавляется идемпотентно при старте).
|
||||
«Оригинальный пост» стал закладкой; при готовом переводе рядом появляется
|
||||
закладка «Перевод» (активна по умолчанию), переключение без перезагрузки
|
||||
(vanilla JS в scripts-блоке). Повторный клик «Перевести» токены не тратит.
|
||||
call_ollama из classifier не подошла (жёстко парсит ответ как JSON) —
|
||||
перевод идёт прямым запросом к Ollama /v1/chat/completions.
|
||||
2026-09-17 — keep-bulk-checkboxes: фикс чекбоксов bulk-выбора. Отметки постов сохраняются
|
||||
в sessionStorage (vesti_bulk_candidates / vesti_bulk_selected) и восстанавливаются
|
||||
при открытии поста (?selected=N); badge sel-count пересчитывается; при submit
|
||||
bulk-формы отметки очищаются. Найден баг: чекбоксы вне <form> (form="bulk-form"),
|
||||
селектор #bulk-form input[name="ids"] не находил их → заменён на input[name="ids"].
|
||||
Коммит c8fe5f7, запушен в gitverse.
|
||||
2026-09-16 — address-field-enhancement ЗААРХИВИРОВАН (openspec/changes/archive/2026-09-16-address-field-enhancement/).
|
||||
(вместо url/channel/feed_url), парсинг по типу crawler; кнопка «Найти» получает
|
||||
название канала из TG API (Telethon get_entity; RSS — feedparser title) и slug из адреса;
|
||||
direction — datalist из реальных значений (DIRECTIONS + БД) + свободный ввод.
|
||||
2026-09-16 — fix-sources-add-channel: в форме добавления источника появилось поле channel
|
||||
(required для telegram через JS), в таблице у TG-источников виден канал.
|
||||
Причина бага: поле channel отсутствовало в UI, хотя валидатор требует его.
|
||||
Заодно: web/templates/sources.html и crawlers.html НЕ были в git (untracked) — sources.html добавлен.
|
||||
2026-09-15 — rss-formatting-preserve: html_to_text/extract_full_text сохраняют абзацы (\n\n);
|
||||
safe_html (bleach) для предпросмотра RSS-текстов; пересобраны старые посты
|
||||
(omarchy 25/25, bleepingcomputer 16/16); lwn частично (403/429 — при след. крауле)
|
||||
2026-09-15 — change selected-layout-stay: «⭐ Отобрать» (single+bulk) больше не уводит на /selected — редирект на /candidates с сохранением группировки/фильтров (post_select → src="candidates", bulk select → back). Отобранные: панели на всю высоту окна (calc(100vh - 160px) вместо 82vh), резюме — отдельным блоком сразу под кнопками «Обработать моделью», редактор «Мой пост» + оригинал растянуты на всю оставшуюся высоту, комментарий + «Опубликовать»/«Сохранить комментарий» — в самом низу (border-top). Пустое место внизу убрано.
|
||||
2026-09-14 — кандидаты: после reject/approve/comment/reclassify/rewrite сохраняется group_by и фильтры (скрытые поля форм + _cand_back); ?status=rejected больше не даёт пустой список (жёсткое status='new' только по умолчанию)
|
||||
2026-09-14 — кандидаты: в списке только внешние new-посты (is_own=0 AND status='new' в _fetch_candidates); свои посты канала (is_own=1) и published/rejected больше не показываются как кандидаты
|
||||
2026-09-14 — фикс published-страницы: заголовок перекрывался fixed-top меню (padding-top 1.5rem→4.5rem в base.html); на карточках добавлен ID поста (#{{p.id}} · направление)
|
||||
2026-09-14 — фикс бага: статус группы в _fetch_candidates брался от первого поста → при group_by=date группа «Ранее на этой неделе» выглядела опубликованной; теперь доминирующий по группе (new > rejected > published)
|
||||
2026-09-13 — fediverse: publisher умеет постить в GoToSocial (@vesti@dedinit.ru)
|
||||
2026-09-13 — publisher снова на Docker; systemd1 ожил
|
||||
2026-09-12 — publisher → systemd user-юнит (костыль, пока сломан systemd1)
|
||||
2026-09-11 — веб-интерфейс: 4 openspec change (CDN/htmx, markdown, даты, фильтр без CDN)
|
||||
-->
|
||||
|
||||
## Текущее состояние
|
||||
- Базовый конвейер (прототип) работает: краулер (8 источников, инкрементальный, метрики), классификатор (Ollama qwen3:8b-nothink + словари), банк статей (bundles + origin:own), веб :8400 (кандидаты/approve/reject/опубликованные/метрики).
|
||||
- **ПРИНЯТЫ В КОНТЕКСТ НЕЗАКОММИЧЕННЫЕ НАРАБОТКИ** (2026-09-20, проверена реализация):
|
||||
- **extract-post-sources** — backend готов и в HEAD: `sources/extract.py` (extract_sources_from_text: Telegram-каналы + сайты, дедуп, лимит 5), `_post_sources` в app.py, `POST /sources/add-from-post`. **Остаток**: блок «Источники из поста» в candidates.html/selected.html НЕ добавлен (задача 4 в tasks.md открыта). Change валиден.
|
||||
- **manual-direction** — готов и в HEAD: `post_reclassify` принимает `manual_direction`, `impl_reclassify` пишет его в БД без LLM; select направлений в selected.html. Change валиден. (Пост 1086 → tech, проверено автором.)
|
||||
- **crawler-очередь** — worker.py (ThreadPoolExecutor, атомарный claim по posts, LLM-семафор к Ollama) + crawl_sources.py (фаза 1: сбор кандидатов) + страница /crawlers (статус alive/dead, очередь, кнопки «Запустить воркер»/«Сбросить dead»). **Файлы UNTRACKED** (worker.py, crawl_sources.py, crawlers.html, sources/extract.py, CONTEXT.md, docs/) — не коммитил (указание пользователя).
|
||||
- **archive-navigation** — ТОЛЬКО ПЛАН (proposal/design/tasks + CONTEXT.md глоссарий): дерево годов/месяцев в /published, роут /published с year/month/page, пагинация по 10, правая колонка sticky, collapse на мобильных. Реализация НЕ начата (все задачи `[ ]`, /published — плоский список).
|
||||
- **rich-repost-card** — tasks 0/13 (не начат), хотя формат публикации уже живёт в publisher/card.py. Требует аудита: проставить статус/закрыть или доделать.
|
||||
- **post-translate — В РАЗРАБОТКЕ** (2026-09-18): в карточках кандидатов и отобранных появилась кнопка **«🌐 Перевести»** — переводит пост на русский локальной моделью (qwen3:8b-nothink, Ollama). Результат сохраняется в `posts.translated_text` (новые посты — колонка добавляется при старте веба), кнопка после перевода скрывается, вместо неё — закладка «Перевод» рядом с «Оригинал» (активна по умолчанию, переключение без перезагрузки на vanilla JS). Повторный клик токены не тратит. Ошибки модели — редирект с `?error=translate:...`. Тест e2e на реальной модели пройден (пост 123: «Kali Purple Essential Training»).
|
||||
- **keep-bulk-checkboxes — ЗААРХИВИРОВАН И ЗАПУШЕН** (2026-09-17, коммит c8fe5f7): проверки массового выбора постов сохраняются между перезагрузками страницы при навигации по постам. Отметки хранятся в sessionStorage браузера (`vesti_bulk_candidates` / `vesti_bulk_selected`); при открытии поста (?selected=N) восстановленные чекбоксы остаются отмеченными, badge sel-count пересчитывается; при submit bulk-формы (Отобрать/Отклонить/Обработать/Опубликовать) отметки очищаются. Изоляция /candidates и /selected независимыми ключами. Попутно исправлен скрытый баг: чекбоксы лежат вне `<form>` (атрибут form="bulk-form") → селектор `#bulk-form input[name="ids"]` не находил их; заменён на глобальный `input[name="ids"]`. Изменены только web/templates/candidates.html и selected.html (frontend, рестарт не нужен).
|
||||
- **address-field-enhancement — ЗААРХИВИРОВАН** (2026-09-16). Сделано и в проде: форма добавления источника — одно поле Address. Для telegram: `https://t.me/mknewsru` → slug=`mknewsru`, название «Мой Компьютер» (Telethon get_entity); для rss: `https://lwn.net/headlines/rss` → slug=`lwn`, название «LWN.net» (feedparser). Кнопка «🔎 Найти» в форме. direction — datalist из реальных значений (DIRECTIONS + БД), свободный ввод остаётся. БД не менялась (address — только UI-концепция, в yaml по-прежнему url/channel/feed_url).
|
||||
- **fix-sources-add-channel** (2026-09-16): добавление telegram-источника через веб снова работает. Поле `channel` добавлено в форму (required для telegram через JS) и показывается в таблице. Причина: UI-форма не имела поля channel. `web/templates/sources.html` ранее отсутствовал в git — добавлен.
|
||||
- **FEDIVERSE-ПУБЛИКАЦИЯ РАБОТАЕТ** (2026-09-13): publisher-service умеет публиковать карточки в GoToSocial (social.dedinit.ru) от имени бот-аккаунта **@vesti@dedinit.ru** (Mastodon-совместимый API). Change `gotosocial-publisher`.
|
||||
- Аккаунт **vesti** создан на GtS (2026-09-13, confirmed+approved, admin=0), OAuth-приложение `vesti-publisher` (client_id=01B09QNDM9YQ2VQVSRXHCVAV6K), токен в .env (`GT_SOCIAL_ACCESS_TOKEN`, scope `read write`), канал `VESTI_GTS_CHANNELS=@vesti@dedinit.ru`.
|
||||
- healthz publisher: блок `gotosocial` — url, account=vesti, token_set=true, account_ok=true.
|
||||
- Реальные тесты: текст → пост, медиа → пост с картинкой (media_attachments=1), delete → 200. Посты-тесты удалены, канал чист.
|
||||
- Канал в запросе: `channels: ["gt:@vesti@dedinit.ru"]` (префикс `gt:`) — fediverse; `@dedinit_vesti` — Telegram. dry_run эмулирует оба.
|
||||
- Базовый конвейер (прототип) работает (далее по-прежнему): краулер, классификатор, банк статей, веб :8400.
|
||||
- **Бэкфилл @dedinit ЗАВЕРШЁН** (2026-09-10): crawler/backfill_dedinit.py, fetched=1040, max_post_id=1092; в БД 846 постов is_own=1, медиа в media/media/ (исторический баг пути: БД media/<file>, файл media/media/<file>; с 2026-09-12 краулер качает в media/<file>, для старых — fallback в make_card). Скрипт оставлен для повторного дозаполнения.
|
||||
- **РЕАЛЬНАЯ ПУБЛИКАЦИЯ РАБОТАЕТ**: бот-контроллер @dedinit_controller_bot (id 7765665742) → @dedinit_vesti. Токен в .env. Проверено: тестовые посты (message_id 2–4).
|
||||
- **publisher-service**: **Docker-контейнер** `vesti-publisher` (docker compose -f services/publisher/docker-compose.yml, 127.0.0.1:8410, restart=unless-stopped, healthy). Compose: env из /.env, TG_PROXY=socks5://host.docker.internal:1080, маунт ../../media/media:/srv/publisher/media:ro. Старый user-юнит `vesti-publisher.service` (~/.config/systemd/user/) — **disabled, не используется** (костыль на время сломанного systemd1, порт 8410 конфликтовал бы).
|
||||
@@ -60,6 +112,17 @@ vesti-web (:8400) ──HTTP POST──▶ publisher-service (:8410) ──Bot A
|
||||
- [x] **Проверено на реальном канале**: publish ушёл в @dedinit_vesti (message_id 6), текст с <b>& доставлен как есть, без превью; тестовые сообщения удалены (delete_message ok). Канал чист
|
||||
- [x] **publisher временно работает как локальный uvicorn** (127.0.0.1:8410, PID 807769) — Docker недоступен из-за зависшего systemd1 на хосте (внешняя проблема, не vesti)
|
||||
|
||||
## Сделано (сессия gotosocial-publisher 2026-09-13)
|
||||
- [x] **OpenSpec change `gotosocial-publisher`** — proposal/design/tasks/specs/gotosocial-publisher, `openspec validate` чисто
|
||||
- [x] services/publisher/app/gotosocial.py — клиент GtS (httpx): post_status (format=markdown), upload_media (v2), verify_credentials, delete_status, GoToSocialError
|
||||
- [x] config.py: gt_social_url/token/visibility/format/channels (+ содержимое .env, .env.example)
|
||||
- [x] channels.py: resolve_channels (TG) + resolve_gotosocial_channels (fediverse из VESTI_GTS_CHANNELS или `gt:`-каналы из запроса)
|
||||
- [x] main.py: `_publish_gotosocial` (upload_media → post_status; status_id в ответе), healthz-блок gotosocial, dry_run для fediverse
|
||||
- [x] **Аккаунт vesti создан** на GtS (admin account create, confirmed+approved), НЕ от имени kpa39l — по запросу пользователя отдельный бот-аккаунт
|
||||
- [x] **OAuth-приложение vesti-publisher** (POST /api/v1/apps, scopes write write:media); токен для vesti: вставлен в БД tokens (user_id из users, scope read write) + записан в .env
|
||||
- [x] **Тесты e2e**: dry_run (fediverse), реальный текст → пост, медиа → пост с картинкой, delete → 200; посты-тесты удалены
|
||||
- [x] **Фикс**: message_id=0 для GtS (id — ULID-строка) → добавлен `status_id: str`
|
||||
|
||||
## Сделано ранее (own-content-hub)
|
||||
- [x] Миграция БД (migrate_own.py): is_own, is_own_canonical, sources.own, published.distributed_dirs
|
||||
- [x] sources.yaml: +dedinit (own: true), синк
|
||||
@@ -67,6 +130,10 @@ vesti-web (:8400) ──HTTP POST──▶ publisher-service (:8410) ──Bot A
|
||||
- [x] Тест e2e (dry-run): approve с dirs [linux,ai] → /published с distributed_dirs
|
||||
|
||||
## В работе / Следующие шаги
|
||||
- [ ] **archive-navigation** (2026-09-20, план готов): дерево годов/месяцев в /published — роут с year/month/page, `_archive_tree(conn)` GROUP BY, шаблон col-lg-3 sticky, collapse на мобильных, пагинация по 10 (tasks.md — 8 задач)
|
||||
- [ ] **extract-post-sources — остаток**: блок «Источники из поста» в candidates.html/selected.html (backend готов, шаблоны нет)
|
||||
- [ ] **rich-repost-card — аудит**: tasks 0/13, формат уже в publisher/card.py — проставить статус/закрыть или доделать
|
||||
- [ ] **Коммит незакоммиченных наработок** (ждёт решения пользователя): worker.py, crawl_sources.py, crawlers.html, sources/extract.py, CONTEXT.md, docs/, openspec changes, правки classifier/config/backup.sh/card.py/.env.example/AGENT*.md
|
||||
- [x] Бэкфилл своего канала @dedinit (задача 2.3) — **ЗАВЕРШЁН** (2026-09-10): fetched=1040, max_post_id=1092, 846 постов is_own=1 в БД
|
||||
- [x] publisher: медиа при Docker-запуске — **исправлено**: compose маунтит ../../media/media:/srv/publisher/media:ro
|
||||
- [x] systemd vesti-web.service + cron per-source + тихий watchdog — **юнит установлен** (подхватится при перезагрузке); **cron**: vesti-crawler-all-sources (`*/30`, тихий) + vesti-watchdog (`*/15`, Telegram при проблемах)
|
||||
@@ -74,7 +141,7 @@ vesti-web (:8400) ──HTTP POST──▶ publisher-service (:8410) ──Bot A
|
||||
- [ ] Проверить результат классификации (в БД: direction заполнены) и выборочно качество на своих постах
|
||||
- [ ] **rich-repost-card в проде**: после оживления systemd1 на хосте пересобрать Docker-контейнер publisher (docker compose -f services/publisher/docker-compose.yml up -d --build), перезапустить веб (подхватит шаблон + comment), проверить approve поста с медиа (send_photo/send_video)
|
||||
- [ ] **Закрыть OpenSpec change rich-repost-card** (статус APPLIED после подтверждения), архивировать
|
||||
- [ ] git-репозитории (gitverse истина, gitea зеркало) — /opt/vesti ещё НЕ git-репо
|
||||
- [x] ~~git-репозитории (gitverse истина, gitea зеркало)~~ — /opt/vesti **git-репо** (2026-09-13): gitverse kpa39l/vesti (main) + gitea-зеркало bigbox
|
||||
- [ ] openspec archive web-изменений (deexternalize-web-assets, web-render-markdown, web-format-datetime, web-list-filter-no-js) и publisher-service / tg-crawler-publisher-prototype (после подтверждения)
|
||||
|
||||
## Как запустить / проверить
|
||||
@@ -87,6 +154,11 @@ docker logs -f vesti-publisher # лог
|
||||
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"}}'
|
||||
# fediverse (GoToSocial @vesti@dedinit.ru):
|
||||
curl -s -X POST http://127.0.0.1:8410/api/v1/publish \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"card":{"text":"Тест fediverse","direction":"linux"},"channels":["gt:@vesti@dedinit.ru"]}'
|
||||
# dry_run (эмуляция без внешних вызовов): добавить "dry_run":true
|
||||
# (старый user-юнит vesti-publisher.service disabled — не использовать, порт занят)
|
||||
# веб (пароль из .env: VESTI_WEB_PASSWORD or ADMIN_PASSWORD):
|
||||
# systemd-юнит: /etc/systemd/system/vesti-web.service (после daemon-reload)
|
||||
@@ -97,8 +169,53 @@ curl -s -X POST http://127.0.0.1:8410/api/v1/publish \
|
||||
CLASSIFY_TIMEOUT=20 .venv/bin/python -m classifier.classify --db db/vesti.db --limit 200
|
||||
```
|
||||
|
||||
## Сделано (сессия закрытия 2026-09-13)
|
||||
- [x] **Классификация направлений приведена к канону** `linux, tech, politics, games, electronics, llm` (AGENT.MD): keywords.py (add politics/llm, dev→tech, убрать media/ai, +golang), classify.py (промпт из DIRECTIONS_CANON + **LLM-fallback при dict-miss**: словарь не дал → всё равно LLM), web/app.py (фильтр из канона). Change `unify-directions` архивирован.
|
||||
- [x] **Переклассификация**: 503 (466 своих + 37 внешних) + 206 (сброшены dev/media/ai) → 966/1019 классифицированы; без direction осталось 53 (шум/личное: музыка, поздравления — не новости). Старые мусорные dev/media/ai устранены.
|
||||
- [x] **git-репозиторий** /opt/vesti: origin gitverse.ru `kpa39l/vesti` (main, private), зеркало gitea bigbox `estorozhenko/vesti` (mirror 8h). .gitignore: backups/ media/ db/ bundles/ logs/ секреты.
|
||||
- [x] **Тест публикации с медиа сквозь веб**: сообщения ушли в @dedinit_vesti (11/12) и удалены. Обнаружен и исправлен фейковый `dry_run` в publisher (поле уехало в PublishResponse вместо PublishRequest; собыл 500). Change `publisher-dry-run-fix` архивирован: `dry_run=true` → эмуляция без TG.
|
||||
- [x] **OpenSpec changes архивированы** (8): fix-approve-dirn, web-media-preview, web-format-datetime, web-list-filter-no-js, deexternalize-web-assets, web-render-markdown, publisher-dry-run-fix, unify-directions.
|
||||
- [x] **Реализован change `candidates-mail-ui`** (двухпанельный интерфейс кандидатов как почтовый клиент): левая панель — список с группировкой (source/date/status, свертываемые <details>) + чекбоксы bulk (approve/reject/reject-old), правая — детали поста (меню действий, комментарий, черновик пересказа, оригинал с медиа). Роуты: POST /candidates/select, /candidates/bulk, /posts/{id}/comment, /posts/{id}/reclassify (qwen), /posts/{id}/rewrite (qwen). БД: добавлены `comment TEXT`, `rewritten_text TEXT`. Без JS (только данные формы). Протестировано TestClient (группировка, bulk-reject, comment, reclassify, rewrite, поиск) — зелёные. Change архивирован (2026-09-13).
|
||||
- [x] **Change `rewrite-as-author`** (2026-09-15): блок «✍️ Переписать» в карточке кандидата — убран `readonly` (textarea теперь редактируемая + кнопка «💾 Сохранить пересказ» → `POST /posts/{id}/rewrite-save`), при публикации пересказ (`rewritten_text`) приоритетнее исходного текста; карточка с пересказом подписывается «✍️ Дед в АйТи (@dedinit) · Источник: …» (от имени автора канала, без «🔗 Оригинал»). Посты без пересказа — как раньше.
|
||||
|
||||
## Текущие задачи (из TODO, открытые)
|
||||
- Интерфейс кандидатов как почтовый клиент (2 панели, группировка, bulk) — ✅ реализован в change candidates-mail-ui (см. выше).
|
||||
- Ручное указание/изменение направления (значок «?» у неклассифицированных)
|
||||
- Переписывание новостей от себя (LLM-пересказ, персональный контент)
|
||||
- Извлечение источников из чужих постов + сервис поиска источников в интернете (дообогащение ТОЛЬКО одобренных)
|
||||
- Страница управления источниками (CRUD + фильтр по типу: Telegram, RSS, почта, сайты, Twitter)
|
||||
|
||||
## Сделано (сессия кандидаты-фиксы 2026-09-14)
|
||||
|
||||
Сессия посвящена трём багам страницы кандидатов, все исправлены через OpenSpec-процесс.
|
||||
|
||||
- [x] **change `candidates-only-external-new`** (коммит `acb49cc`): в списке кандидатов показывались одобренные к публикации посты и «все посты как свои».
|
||||
- Причина: в `_fetch_candidates` при пустом фильтре статуса НЕ было фильтрации `p.status`, поэтому в список попадали published (6) и rejected (3); а 846 постов обратного филла своего канала @dedinit (`is_own=1`, status=new) при группировке по дате заполняли группы 04-06.2026 полностью и выглядели «своими» (⭐), хотя пользователь их не одобрял.
|
||||
- Решение (по выбору пользователя, вариант B): жёстко `p.is_own=0 AND p.status='new'` — кандидаты = только внешние new-посты. Свои посты канала не кандидаты, управляются через fan-out/«Свои».
|
||||
- Итог: 164→163 поста, все внешние new; группы 04-06.2026 (чисто свои) исчезли; 0 бейджей «СВОЙ».
|
||||
- [x] **change `keep-candidates-context`** (коммит `0999424`): после «Отклонить» при группировке по дате открывалась пустая страница («нет кандидатов по фильтру») и фильтр сбрасывался на «источник».
|
||||
- Баг 1: жёсткое `p.status='new'` конфликтовало с явным `?status=rejected` (`new AND rejected` = пусто).
|
||||
- Баг 2: редирект после reject жёстко шёл на `/candidates?status=rejected` без `group_by`.
|
||||
- Решение: жёсткое `new` только при пустом статусе; явный статус — единственный фильтр. Хелпер `_cand_back()` строит URL возврата с сохранением group_by/direction/own/q; во все формы правой панели (approve/reclassify/rewrite/reject/comment) добавлены скрытые поля контекста.
|
||||
- Итог: `?status=rejected` показывает 4 поста (не пусто); после reject остаёшься в своей группировке.
|
||||
|
||||
### Итоговое состояние страницы кандидатов (после сессии)
|
||||
- Список: только внешние new-посты (сейчас 163), без published/rejected и без своих.
|
||||
- Группировка (date/source/status) и фильтры сохраняются после любых действий из правой панели.
|
||||
- Тихие OpenSpec-change: `candidates-only-external-new`, `keep-candidates-context` — задачи [x], валидны; архивировать после подтверждения.
|
||||
- Правый блок действий: кнопки «🤖 Обработать моделью / ✍️ Переписать / 🚫 Отклонить» вынесены в ОДИН ряд НАД полем комментария, «✅ Опубликовать» — в начале approve-формы, поле комментария + «💾 Сохранить комментарий» — ниже (change `candidates-buttons-order`).
|
||||
- Список кандидатов: флаг «прочитан» — непрочитанные карточки жирные, выбранный пост — светло-голубая заливка (класс active-row), открытие поста помечает is_read=1; группировка по умолчанию — «дата» (хронология получения) (change `candidates-read-flag`).
|
||||
- **Пайплайн «Кандидаты → Отобранные → Опубликованные» (change `selected-list`)**: новый статус `selected`; в КАНДИДАТАХ — только «⭐ Отобрать» (single+bulk), вся обработка (классификация/переписывание/комментарий/публикация) — в «⭐ Отобранных» (отдельный список в навбаре, `/selected`); классификация — только по кнопке «🤖 Обработать моделью» (токены на кандидатов не тратятся; bulk «Обработать» из кандидатов убран).
|
||||
- **Подготовка своего поста (в «Отобранных»)**: рядом с оригиналом — поле «✍️ Мой пост» (textarea rewritten_text, ВСЕГДА видно), куда «✍️ Переписать» кладёт черновик модели; текст редактируется и сохраняется (публикация идёт им, а не репостом оригинала); «✅ Опубликовать» и «💾 Сохранить комментарий» — под полем комментария.
|
||||
|
||||
## Ключевые артефакты
|
||||
- /opt/vesti/AGENT.MD — правила проекта (обязательно: все изменения через OpenSpec)
|
||||
- /opt/vesti/crawler/rss_crawler.py — RSS/Atom-краулер (feedparser+httpx, etag/modified, trafilatura, rss_state; lwn первый источник)
|
||||
- /opt/vesti/crawler/worker.py — фаза 3: параллельная обработка очереди кандидатов (ThreadPoolExecutor, атомарный claim по posts, лимит MAX_CONCURRENT_LLM к Ollama)
|
||||
- /opt/vesti/crawler/crawl_sources.py — фаза 1: сбор кандидатов с источников (telegram+rss) в очередь
|
||||
- /opt/vesti/web/templates/crawlers.html + /crawlers — статус краулеров (alive/dead, last_fetch, ошибки) и размер очереди (pending/processing/done), кнопки «Запустить воркер» и «Сбросить dead»
|
||||
- /opt/vesti/web/templates/sources.html + /sources — страница управления источниками (add/edit/toggle-пауза/delete с confirm; источник истины — sources.yaml → полный ре-синк в БД; каскад при удалении: посты остаются с source_id=NULL)
|
||||
- /opt/vesti/openspec/changes/rss-crawler/ — OpenSpec change RSS-краулера (proposal/design/tasks/spec)
|
||||
- /opt/vesti/openspec/changes/{deexternalize-web-assets, web-render-markdown, web-format-datetime, web-list-filter-no-js}/ — 4 change веб-интерфейса (валидны, задачи [x])
|
||||
- /opt/vesti/openspec/changes/rich-repost-card/ — change формата публикации (proposal/design/tasks/specs/tg-publisher, валиден)
|
||||
- /opt/vesti/services/publisher/ — микросервис (app/, Dockerfile, compose, README)
|
||||
@@ -113,6 +230,6 @@ CLASSIFY_TIMEOUT=20 .venv/bin/python -m classifier.classify --db db/vesti.db --l
|
||||
- **publisher в проде = Docker** (2026-09-13): systemd1 (D-Bus org.freedesktop.systemd1) после перезагрузки ожил, контейнер vesti-publisher (compose, restart=unless-stopped) работает healthy. User-юнит vesti-publisher.service выключен (disabled) и не используется. Если снова сломается systemd1 — вернуть юнит: systemctl --user enable --now vesti-publisher.service.
|
||||
- Fan-out: сейчас все каналы из VESTI_BOT_CHANNELS (один). В будущем — направления→каналы (маппинг).
|
||||
- ~~Бэкфилл канала (задача 2.3)~~ — ЗАВЕРШЁН (2026-09-10): 1040 постов, 846 в БД is_own=1
|
||||
- git-репозиторий /opt/vesti — инициализировать?
|
||||
- ~~git-репозиторий /opt/vesti~~ — СДЕЛАН (2026-09-13): gitverse kpa39l/vesti (main) + gitea-зеркало bigbox (8h)
|
||||
- systemd daemon-reload на bigbox зависает (внешняя проблема, не vesti) — юнит подхватится при перезагрузке
|
||||
- Качество классификации своих постов (846, словари vs LLM) — проверить выборочно
|
||||
- ~~Качество классификации~~ — ПРИВЕДЕНО К КАНОНУ (2026-09-13): 966/1019 с direction; 53 без — шум/личное
|
||||
@@ -3,6 +3,10 @@
|
||||
Формат: | дата | задача | статус | закрыта в |
|
||||
|---|---|---|---|
|
||||
| 2026-09-08 | Инициализировать OpenSpec-проект /opt/vesti, change tg-crawler-publisher-prototype | ✅ закрыта | сессия старта |
|
||||
| 2026-09-14 | Фикс: статус группы при group_by=date брался от первого поста → группы с первым published выглядели опубликованными | ✅ закрыта | change fix-date-group-status |
|
||||
| 2026-09-14 | Фикс /published: заголовок перекрывался fixed-top меню; на карточках нет ID поста | ✅ закрыта | change fix-published-page |
|
||||
| 2026-09-14 | Кандидаты: в списке не должно быть одобренных/своих постов; при группировке по дате всё показывалось как «свои» | ✅ закрыта | change candidates-only-external-new (в списке только внешние new: is_own=0 AND status='new') |
|
||||
| 2026-09-14 | Кандидаты: после reject сбрасывается группировка (date→source), ?status=rejected показывает пустой список | ✅ закрыта | change keep-candidates-context (сохранение group_by/фильтров через скрытые поля + _cand_back; явный статус — единственный фильтр) |
|
||||
| 2026-09-08 | Артефакты change: proposal, design, specs (5), tasks | ✅ закрыта | сессия старта |
|
||||
| 2026-09-08 | PRD.md — требования, архитектура, стек, DoD | ✅ закрыта | сессия старта |
|
||||
| 2026-09-08 | Каркас проекта: каталоги, venv, requirements.txt | ✅ закрыта | сессия старта |
|
||||
@@ -47,10 +51,43 @@
|
||||
| 2026-09-11 | Change `web-list-filter-no-js`: фильтрация GET-формами без JS/CDN | ✅ закрыта | 2026-09-11 (кнопка «Применить», без ожидания CDN) |
|
||||
| 2026-09-11 | Баг бандлов: двойной слэш `/bundle//opt/vesti/...` в published | ✅ закрыта | 2026-09-11 (create_bundle → относительный путь, 4 строки БД обновлены) |
|
||||
| 2026-09-11 | AGENT.MD — правила проекта (все изменения через OpenSpec) | ✅ закрыта | 2026-09-11 |
|
||||
| 2026-09-09 | git-репозитории (gitverse истина, gitea зеркало) — /opt/vesti НЕ git-репо | 🔵 открыта | |
|
||||
| 2026-09-08 | openspec archive web-изменений + own-content-hub + publisher-service (после подтверждения) | 🔵 открыта | |
|
||||
| 2026-09-09 | git-репозитории (gitverse истина, gitea зеркало) — /opt/vesti НЕ git-репо | ✅ закрыта | 2026-09-13 (дубликат строки ниже: git init + коммит, gitverse kpa39l/vesti, gitea зеркало 8h) |
|
||||
| 2026-09-08 | openspec archive web-изменений + own-content-hub + publisher-service (после подтверждения) | ✅ закрыта | 2026-09-16 (архивированы 14 change-ов: 12 веб/фиксы+crawler-queue + own-content-hub + publisher-service; спеки влиты в openspec/specs) |
|
||||
| 2026-09-13 | После перезагрузки не поднялся веб :8400 (юнит vesti-web был disabled) + publisher возвращён с user-юнита на Docker | ✅ закрыта | 2026-09-13 (vesti-web enabled; docker compose up -d --build; vesti-publisher healthy; watchdog переписан на docker-проверку) |
|
||||
| 2026-09-13 | Бэкап VESTI на Яндекс.Диск (по аналогии icq/netbox) | ✅ закрыта | 2026-09-13 (backup.sh + backups/ + ЯД vesti-backups, cron root 45 2) |
|
||||
| 2026-09-13 | approve → 500 (UnboundLocalError 'dirn' в web/app.py) | ✅ закрыта | 2026-09-13 (change fix-approve-dirn; dirn/lang перенесены до использования; 303 вместо 500) |
|
||||
| 2026-09-13 | Медиа в карточках новостей не отображаются | ✅ закрыта | 2026-09-13 (change web-media-preview; роут /media/{filename} + превью img/video в candidates/published; клик → полноразмер в новой вкладке) |
|
||||
| 2026-09-13 | Пост без классификации: на карточке значок «?» — понять его роль; если классификации нет — дать возможность вручную указывать/изменять/добавлять направление при анализе | 🔵 открыта | |
|
||||
| 2026-09-13 | Пост без классификации: на карточке значок «?» — понять его роль; если классификации нет — дать возможность вручную указывать/изменять/добавлять направление при анализе | ✅ закрыта | 2026-09-16 (change manual-direction: select направлений в «Отобранных» у «🤖 Обработать моделью»; manual_direction пишется в БД без LLM; проверено: пост 1086 → tech) |
|
||||
| 2026-09-13 | git-репозиторий /opt/vesti (gitverse истина, gitea зеркало) | ✅ закрыта | 2026-09-13 (git init + commit; gitverse kpa39l/vesti main; gitea estorozhenko/vesti mirror 8h; .gitignore: backups/media/db/bundles/logs исключены) |
|
||||
| 2026-09-13 | Интерфейс кандидатов: разделить окно на 2 панели — слева список кандидатов, справа меню действий с постом (опубликовать / отклонить / переклассифицировать / переписать новость под себя), ниже блок для моего комментария, потом оригинальный пост со всеми медиа | ✅ закрыта | 2026-09-16 (двухпанельный mail-UI: candidates-mail-ui 13.09 + selected-list 15.09 — обработка/переклассификация/переписывание/публикация/комментарий/медиа перенесены в «Отобранные» (пайплайн кандидаты→отобранные→опубликованные); в кандидатах осталось отобрать/отклонить) |
|
||||
| 2026-09-13 | Контент-стратегия: не перепубликовать чужие новости, а публиковать переписанные от себя (персональный контент, добавление ценности). Связано с «переписать новость под себя» в интерфейсе кандидатов — нужна генерация пересказа через LLM + редактирование перед публикацией | ✅ закрыта | 2026-09-15 (change rewrite-as-author: textarea редактируема + кнопка сохранения; публикация идёт с пересказом от имени @dedinit) |
|
||||
| 2026-09-13 | Извлечение источников новостей из полученных постов (каналы/сайты, откуда пришли новости) — чтобы в следующий раз забирать новости напрямую из источника. ДОПОЛНЕНИЕ: возможно стоит разработать сервис поиска источников в интернете (если источник не указан) и ДООБОГАЩАТЬ карточки кандидатов, но — чтобы не тратить лишние деньги — ТОЛЬКО для одобренных кандидатов. Пайплайн: отбор новостей из списка кандидатов → в «выбранные» → обработка/дообогащение/переписывание при необходимости → публикация | 🔵 открыта | (зафиксирована, проработать) |
|
||||
| 2026-09-13 | OpenSpec change `gotosocial-publisher`: proposal/design/tasks/specs/gotosocial-publisher, validate чисто | ✅ закрыта | 2026-09-13 (архивирован как 2026-09-13-gotosocial-publisher, delta влита в specs/gotosocial-publisher/spec.md) |
|
||||
| 2026-09-13 | Аккаунт-бот **vesti** на GtS (admin account create, confirmed+approved, admin=0) — публиковать НЕ от kpa39l | ✅ закрыта | 2026-09-13 (авто-подтверждение; токен в .env GT_SOCIAL_ACCESS_TOKEN) |
|
||||
| 2026-09-13 | OAuth-приложение vesti-publisher (POST /api/v1/apps, scopes write write:media) + user-токен vesti (вставка в tokens, user_id из users) | ✅ закрыта | 2026-09-13 (client_id=01B09QNDM9YQ2VQVSRXHCVAV6K; verify_credentials → username=vesti) |
|
||||
| 2026-09-13 | publisher: модуль gotosocial.py (httpx: post_status/upload_media/verify_credentials/delete_status), config gt_social_*, channels gt:, main.py _publish_gotosocial + healthz + dry_run | ✅ закрыта | 2026-09-13 (реальные тесты: текст, медиа, delete; status_id ULID; канал @vesti@dedinit.ru) |
|
||||
| 2026-09-13 | Доки + git + backup по gotosocial-publisher (STATUS/WALKTHROUGH/.env.example; коммиты bf176cc, 6b955aa на gitverse) | ✅ закрыта | 2026-09-13 (backup.sh в фоне — уведомление отдельно) |
|
||||
| 2026-09-13 | Пометить аккаунт vesti как bot (PATCH /api/v1/accounts/update_credentials bot:true или actor_type Service) | ✅ закрыта | 2026-09-16 (PATCH update_credentials bot=true, verify: bot=True) |
|
||||
| 2026-09-13 | Фан-аут направлений → каналы (в т.ч. fediverse-каналы по направлениям) | 🔵 открыта | (расширение публикации) |
|
||||
| 2026-09-13 | Отдельная страница управления источниками новостей: отображение / добавление / удаление / редактирование, фильтр по типу источника (Telegram, RSS, почтовая рассылка, прямые новости с сайтов, Twitter и т.п.) | ✅ закрыта | change sources-admin; /sources + CRUD (add/edit/toggle/delete), yaml — источник истины |
|
||||
| 2026-09-14 | Метрики: добавить инсайты — сколько в день новых постов получено / одобрено / опубликовано, по аналогии с существующими карточками метрик; плюс за неделю и месяц (итого 9 карточек: день/неделя/месяц × новые/одобрено/опубликовано) | 🔵 открыта | (задача на потом, просто зафиксировать) |
|
||||
| 2026-09-15 | **rss-crawler**: OpenSpec change (proposal/design/tasks/spec), код rss_crawler.py, lwn | ✅ закрыта | change rss-crawler |
|
||||
| 2026-09-15 | **rss-crawler**: cron vesti-rss-crawler (каждые 15 мин, no_agent, тихий) | ✅ закрыта | cron fa60bc830843 |
|
||||
| 2026-09-15 | **rss-crawler**: собрать первые RSS-посты (15, lwn) | ✅ закрыта | 2026-09-15 |
|
||||
| 2026-09-15 | **rss-crawler**: веб-скедулинг RSS (кнопка запуска краулера в админке) | 🔵 открыта | (зафиксирована, проработать) |
|
||||
| 2026-09-15 | **crawler-очередь**: отдельная страница /crawlers — статус работы краулеров (alive/dead, last_fetch, ошибки) и размер очереди. Двухфазный сбор: (1) краулеры проходят по источникам и ищут новые кандидаты, (2) найденные кладутся в очередь, (3) воркеры разбирают очередь в несколько потоков | ✅ закрыта | change crawler-queue; /crawlers, worker.py (ThreadPoolExecutor) |
|
||||
| 2026-09-15 | Кнопки правой панели: «Обработать моделью / Переписать / Отклонить» — ОДИН ряд НАД полем комментария | ✅ закрыта | change candidates-buttons-order (коммит 562d787) |
|
||||
| 2026-09-15 | После «🚫 Отклонить» оставаться на странице кандидатов в текущей группировке (не уходить в отклонённые) | ✅ закрыта | change reject-stay-candidates (коммит 562d787) |
|
||||
| 2026-09-15 | Флаг прочитанности: непрочитанные карточки жирные, открытый пост — светло-голубая заливка, дефолтная группировка по дате | ✅ закрыта | change candidates-read-flag (коммит c09dff0) |
|
||||
| 2026-09-15 | «✅ Опубликовать» — НИЖЕ поля комментария рядом с «💾 Сохранить комментарий»; оригинальный пост растянут на всю высоту панели | ✅ закрыта | коммит b542e9b |
|
||||
| 2026-09-15 | **Пайплайн «Кандидаты → Отобранные → Опубликованные»**: статус selected, /selected отдельный список, в кандидатах только «⭐ Отобрать», вся обработка в отобранных, классификация по кнопке | ✅ закрыта | change selected-list (коммит ae8af8d) |
|
||||
| 2026-09-15 | Подготовка своего поста в «Отобранных»: поле «✍️ Мой пост» (rewritten_text всегда видно) рядом с оригиналом — черновик «Переписать» редактируется и публикуется | ✅ закрыта | change selected-list (коммит ae8af8d) |
|
||||
| 2026-09-17 | Фикс: при открытии поста (?selected=N) чекбоксы массового выбора на /candidates и /selected сбрасывались, оставался только чекбокс открытого поста. Нужно сохранять отметки при навигации для быстрого массового отказа/отбора | ✅ закрыта | change keep-bulk-checkboxes (sessionStorage vesti_bulk_candidates/vesti_bulk_selected; коммит c8fe5f7, запушен в gitverse; баг-фикс: селектор input[name=ids] вместо #bulk-form input) |
|
||||
| 2026-09-18 | Перевод поста на русский в карточках (кандидатов и отобранных): метка «Оригинал» → закладка, кнопка «🌐 Перевести» → закладка «Перевод». Перевод локальной моделью qwen3:8b-nothink, результат в posts.translated_text (идемпотентный ALTER при старте). Повторный клик без вызова модели. call_ollama не подошла (парсит JSON) — прямой запрос к Ollama /v1 | ✅ закрыта | change post-translate (коммит 4a166fa, запушен в gitverse; e2e на реальной модели пройден) |
|
||||
| 2026-09-18 | **Кросспостинг личных источников** (идея пользователя): Телеграм (@dedinit, свой канал), fediverse GtS (@kpa39l@social.dedinit.ru), блог dedinit.ru (Hugo) должны обмениваться данными и кросспоститься — написанное в Телеграме появляется в блоге, ВК и fediverse; эпогей — в моих новостных каналах (VESTI). Требуется: система-оркестратор (подобно publisher VESTI), интерфейсы: TG (Telethon/Bot API), VK API (VK.ru — личная страница, нужен токен/подтверждение), GtS Mastodon API (есть токен vesti; для kpa39l — отдельный), Hugo-блог (git+deploy). Обмен данными между источниками (двусторонний), дедуп, не перезаписывать чужое | 🔵 открыта | (идея зафиксирована, спроектировать отдельный change) |
|
||||
| 2026-09-18 | **Markdown-редактор по аналогии с Dillinger/StackEdit** — ОТДЕЛЬНЫЙ модуль для встраивания в другие проекты (сначала в VESTI), основа блогоплатформы chronicle (c7e.ru, гипотеза). Требования пользователя: блоки кода с кнопкой «Скопировать»; включение/выключение нумерации строк (копирование без номеров); подсветка синтаксиса по выбранному языку; выделение инлайн-команд в тексте; живой предпросмотр + GFM. Репозиторий: gitverse kpa39l/chronicle.nixg.ru (клон /opt/chronicle.nixg.ru, ветка master) — там это Фаза 2 ROADMAP.md (задачи 2.1–2.5: веб-редактор React/Vue, Monaco Editor, языки Python/JS/Go/Rust/Bash/SQL, темы, кнопка «скопировать», Markdown-режим+WYSIWYG) | ✅ перенесена | 2026-09-20: вынесена в отдельный проект /opt/md-editor (README, ADR, glossary, openspec change md-editor-core) |
|
||||
| 2026-09-20 | **Приём незакоммиченных наработок** (проверено реализацию): extract-post-sources — backend (sources/extract.py: TG+сайты, дедуп, лимит 5; _post_sources; POST /sources/add-from-post) + manual-direction (impl_reclassify пишет manual_direction без LLM; select в selected.html) — код в HEAD, валидны. crawler-очередь: worker.py (ThreadPoolExecutor, атомарный claim, LLM-семафор) + crawl_sources.py + страница /crawlers (шаблон crawlers.html) — работают, но файлы UNTRACKED (не коммитил) | ✅ принято | 2026-09-20 (в контекст; код не коммичен — по указанию пользователя) |
|
||||
| 2026-09-20 | **archive-navigation** (план, реализация не начата): дерево годов/месяцев в /published (роут /published с year/month/page, _archive_tree, шаблон col-lg-3 sticky, collapse на мобильных, пагинация по 10) | 🔵 открыта | (планируемая) |
|
||||
| 2026-09-20 | **extract-post-sources — остаток**: блок «Источники из поста» в candidates.html/selected.html (шаблонный блок не добавлен; backend готов) | 🔵 открыта | (планируемая) |
|
||||
| 2026-09-20 | **rich-repost-card — аудит**: tasks 0/13 — не начат, хотя формат уже живёт в publisher/card.py (frontmatter-остаток). Проставить статус/закрыть или доделать | 🔵 открыта | (планируемая) |
|
||||
| 2026-09-20 | **Коммит незакоммиченных наработок** vesti (classifier, crawler/, docs/, openspec changes archive-navigation/extract-post-sources/manual-direction, sources/extract.py, web/templates/crawlers.html) | 🔵 открыта | (ждёт решения пользователя: коммитить или автор сам) |
|
||||
+270
@@ -401,3 +401,273 @@ sudo systemctl restart vesti-web # active; login → 200, candidates/published
|
||||
2. Тест публикаци с медиа сквозь веб (approve с media_path → publisher send_photo) — если ещё не проверен.
|
||||
3. git-репозиторий /opt/vesti (gitverse истина, gitea зеркало).
|
||||
4. openspec archive: fix-approve-dirn, web-media-preview, rich-repost-card + старые changes (после подтверждения).
|
||||
|
||||
---
|
||||
|
||||
## Сессия 2026-09-13 (закрытие проекта vezde + новые фичи)
|
||||
|
||||
### 1. Унификация направлений классификации (change unify-directions, архивирован)
|
||||
Проблема: список направлений разъехался по 4 местам (classify.py:87 промпт, web/app.py:130 фильтр, keywords.py словари, канон AGENT.MD).
|
||||
- Канон: **linux, tech, politics, games, electronics, llm**.
|
||||
- keywords.py: `DIRECTIONS_CANON` + словари приведены (dev→tech, ai→llm, media удалён, politics добавлен).
|
||||
- classify.py: промпт LLM из `DIRECTIONS_CANON`; импорт `find_direction, DIRECTIONS_CANON`.
|
||||
- **LLM-fallback**: раньше LLM звалась ТОЛЬКО если словарь дал direction; при dict-miss пост оставался серым навсегда. Теперь при dict-miss тоже зовём LLM (проверка канона). Это убрало 449 серых постов.
|
||||
- Убраны однобуквенные ключи `"c"`/`"go"` (шумят: `\bc\b` матчится на любую `c`). `qt/vr/ии/ai/ml` — валидны (границы слов).
|
||||
- web/app.py: фильтр направлений = канон.
|
||||
- **Питфол**: keywords.py перезаписан write_file → обрезался (117→99 строк, потерян хвост find_directions_all/find_direction/__main__). Восстанавливать по контракту из classify.py (find_direction→str|None, find_directions_all→list[str]).
|
||||
- Переклассификация: 503 (466 своих+37 внешних) + 206 сброшенных (dev/media/ai→classified=0) → **966/1019** классифицированы. Осталось 53 без direction — шум/личное (музыка, поздравления), не новости.
|
||||
|
||||
### 2. Тест публикации с медиа + фикс dry_run (change publisher-dry-run-fix, архивирован)
|
||||
- Тест: `POST /api/v1/publish` с dry_run:true + media → сообщения РЕАЛЬНО ушли в @dedinit_vesti (message_id 11=медиа, 12=текст). **dry_run был мёртвой опцией** (поле в PublishResponse, а не PublishRequest).
|
||||
- Исправлено: `dry_run: bool = False` в PublishRequest; в роуте — ветка «эмулировать результат» (message_id=0, dry_run=true) ДО цикла по каналам.
|
||||
- **Питфол**: после первой пересборки — 500 (AttributeError: dry_run в PublishResponse). Поле добавлено в PublishRequest → работает: dry_run=true → {ok, message_id:0, dry_run:true}, в TG ничего не уходит.
|
||||
- Тестовые сообщения удалены через `delete_message('@dedinit_vesti', 11/12)` (в publisher есть такой метод).
|
||||
|
||||
### 3. git-репозиторий (задача «git репозиторий?»)
|
||||
- `git init -b main`, .gitignore (backups/ media/ db/ bundles/ logs/ секреты), первый коммит c3f59f7.
|
||||
- gitverse: `POST $GITVERSE_API/user/repos` (Bearer PAT + Accept `application/vnd.gitverse.object+json;version=1`, auto_init:false) → kpa39l/vesti (id 334405). Remote: `https://kpa39l:<PAT>@gitverse.ru/kpa39l/vesti.git`.
|
||||
- Gitea-зеркало bigbox: `POST :3000/api/v1/repos/migrate` (clone_addr `https://oauth2:<PAT>@gitverse.ru/...`, mirror:true, mirror_interval:8h) → estorozhenko/vesti.
|
||||
- **Питфол**: первый migrate таймаутнул (180с — gitea клонировал) и оставил пустой repo с mirror=True но без данных → «Repository is not a mirror» на mirror-sync. Удалить (DELETE) и пересоздать через migrate заново (дождаться 201 — миграция синхронизирует при создании).
|
||||
|
||||
### 4. Классификация: задачи и термины
|
||||
- Новые задачи в TODO (5 открытых): интерфейс кандидатов (почтовый клиент), ручное направление («?» = direction пустой — см. candidates.html:41), переписывание от себя (LLM-пересказ), извлечение/поиск источников (дообогащение только одобренных), страница управления источниками (CRUD+фильтр типов).
|
||||
- Change **candidates-mail-ui** (готовится): 2 панели (список слева + детально справа), группировка по source/date/status, bulk approve/reject, comment, reclassify, rewrite, медиа. proposal/design/tasks/spec — валидны.
|
||||
|
||||
### Следующая сессия
|
||||
1. ✅ **candidates-mail-ui реализован** (2026-09-13): двухпанельный интерфейс (список слева с группировкой source/date/status + чекбоксы bulk, детали справа с действиями: approve/reject/reclassify/rewrite/comment), комментарий — поле `posts.comment`, переписывание — qwen пишет черновик в `posts.rewritten_text`, переклассификация — перезапуск classify с qwen. Без JS. Архив: 2026-09-13-candidates-mail-ui.
|
||||
2. Ручное указание направления (для 53 без direction + «?» на карточке).
|
||||
3. Переписывание новостей от себя (LLM-пересказ) + пайплайн «выбранные → дообогащение → публикация».
|
||||
4. Извлечение источников из постов + сервис поиска в интернете (только одобренные).
|
||||
5. Страница управления источниками (CRUD + фильтр по типу).
|
||||
|
||||
## 2026-09-13 — gotosocial-publisher: публикация VESTI в GoToSocial (@vesti@dedinit.ru)
|
||||
|
||||
### Контекст
|
||||
- Пользователь: «хочу публиковать в свой gotosocial из проекта vesti». Проект /opt/federation не существует — реальная цель: /opt/gotosocial (нода social.dedinit.ru, Docker-контейнер gotosocial, :8082→:8080, версия 0.22.1).
|
||||
- Решение: НЕ от имени kpa39l (личный аккаунт), а **отдельный бот-аккаунт `vesti`** — «пользователя-бота, который новости и постит».
|
||||
|
||||
### Реализация
|
||||
1. **OpenSpec change `gotosocial-publisher`** (proposal/design/tasks/specs/gotosocial-publisher/spec.md), `openspec validate` чисто. Валиден.
|
||||
2. **Аккаунт vesti**: `docker exec gotosocial /gotosocial/gotosocial admin account create --username vesti --email vesti@dedinit.ru --password <rand>` → confirmed+approved, admin=0.
|
||||
- Питфол: пароль передал в CLI как переменную (не в хронологию); подтверждение прошло автоматически (confirmed_at заполнен).
|
||||
3. **OAuth-приложение** `vesti-publisher` через `POST /api/v1/apps` (Mastodon API): scopes `write write:media`, redirect `urn:ietf:wg:oauth:2.0:oob` → client_id=01B09QNDM9YQ2VQVSRXHCVAV6K.
|
||||
- Питфол: app-токен (client_credentials) НЕ подходит — verify_credentials требует привязки к user; для сервер-сервер нужен user-токен.
|
||||
4. **User-токен vesti**: вставлен в БД `tokens` (SQLite /opt/gotosocial/data/sqlite.db):
|
||||
- Питфол: **user_id должен быть id из таблицы `users` (не `accounts`!)** — иначе verify_credentials → null; scope `read write`; access 48 симв.
|
||||
- Проверка: `GET /api/v1/accounts/verify_credentials` с Bearer → username=vesti. Токен записан в /opt/vesti/.env как GT_SOCIAL_ACCESS_TOKEN.
|
||||
5. **Код publisher** (services/publisher/app/): gotosocial.py (httpx-клиент: post_status c format=markdown, upload_media v2, verify_credentials, delete_status), config.py (gt_social_*), channels.py (resolve_gotosocial_channels; `gt:`-каналы из запроса), main.py (_publish_gotosocial, healthz-блок gotosocial, dry_run).
|
||||
- Питфол: `gt:@vesti@dedinit.ru` трактовался и как TG — resolve_channels теперь отбрасывает `gt:`-префикс.
|
||||
- Питфол: message_id=0 для GtS (id — ULID-строка, не int) → добавлен `status_id: str` в ответ.
|
||||
6. **Тесты e2e** (все реальные, потом удалены delete): текст → пост (status 01M2E6AC...), медиа → пост с картинкой (media_attachments=1), delete → HTTP 200. Канал @vesti@dedinit.ru чист.
|
||||
7. **Доки**: STATUS.md (раздел fediverse + «Сделано»), .env.example (GT_SOCIAL_*), tasks.md (все [x]).
|
||||
|
||||
### Команды
|
||||
```bash
|
||||
docker exec gotosocial /gotosocial/gotosocial admin account create --username vesti --email vesti@dedinit.ru --password '<rand>'
|
||||
curl -s -X POST https://social.dedinit.ru/api/v1/apps -H 'Content-Type: application/json' \
|
||||
-d '{"client_name":"vesti-publisher","redirect_uris":"urn:ietf:wg:oauth:2.0:oob","scopes":"write write:media"}'
|
||||
# токен — вставка в sqlite (python3, см. выше), затем в .env
|
||||
curl -s http://127.0.0.1:8410/healthz # gotosocial: account=vesti, token_set=true, account_ok=true
|
||||
curl -s -X POST http://127.0.0.1:8410/api/v1/publish -H 'Content-Type: application/json' \
|
||||
-d '{"card":{"text":"Тест","direction":"linux"},"channels":["gt:@vesti@dedinit.ru"]}'
|
||||
```
|
||||
|
||||
### Следующая сессия
|
||||
1. Проработать **candidates-mail-ui** (2 панели) — открытые вопросы см. предыдущая сессия.
|
||||
2. Закрыть rich-repost-card (архив) + архивировать gotosocial-publisher после подтверждения.
|
||||
3. Пометить аккаунт vesti как **bot** (в GtS 0.22.1 bot-флаг: PATCH /api/v1/accounts/update_credentials → bot:true, либо actor_type Service) — не критично, желательно.
|
||||
4. Фан-аут направлений → каналы (в т.ч. fediverse-каналы по направлениям).
|
||||
|
||||
## Сессия 2026-09-14 — кандидаты: внешние new-посты + сохранение группировки
|
||||
|
||||
### Контекст
|
||||
Два раунда багов страницы /candidates, оба закрыты через OpenSpec.
|
||||
|
||||
### Что сделано
|
||||
1. **change `candidates-only-external-new`** (коммит `acb49cc`):
|
||||
- Баг: в списке кандидатов показывались одобренные (published) и отклонённые (rejected) посты; при группировке по дате все посты выглядели «своими» (846 backfill @dedinit, is_own=1, status=new — группы 04-06.2026 целиком свои). Пользователь: «в списке кандидатов не должно быть одобренных», «они не мои, я их не одобрял».
|
||||
- Диагностика: `_fetch_candidates` при пустом `status` (фильтр «Все» = дефолт) НЕ добавлял `p.status` → в списке все статусы. Свои посты — не кандидаты.
|
||||
- Решение (вариант B — выбор пользователя через clarify): жёстко `p.is_own=0 AND p.status='new'`. Separately: counts табов пересчитаны отдельным запросом (внешние: new=164, published=5, rejected=3), чтобы не показывать 0/0.
|
||||
- Проверка: юнит-скрипт + живой curl (0 «СВОЙ», 164 поста, только внешние источники); группы 04-06.2026 исчезли.
|
||||
2. **change `keep-candidates-context`** (коммит `0999424`):
|
||||
- Баг (репорт пользователя): «когда я в группировке дата отклонил кандидата, открылась страница с пустым списком „нет кандидатов по фильтру" и фильтр сбросился на „источник"; я должен остаться на той же группировке».
|
||||
- Причины: (1) жёсткое `p.status='new'` AND явный `status=rejected` → пустая выборка; (2) редирект `/posts/{id}/reject` жёстко на `/candidates?status=rejected` без group_by.
|
||||
- Решение: жёсткое `new` только при пустом параметре статуса (явный статус — единственный фильтр); хелпер `_cand_back()` строит URL возврата с group_by/direction/own/q; во все формы правой панели (approve/reclassify/rewrite/reject/comment) добавлены скрытые поля контекста.
|
||||
- Проверка: юнит (`status=rejected` → 4 поста, published → 5, дефолт → 163 new внешних) + живой curl (скрытые поля в 6 формах, `?group_by=date&status=rejected` → 4 поста, 0 «нет кандидатов»).
|
||||
3. Рестарт `vesti-web` (syntax OK → systemctl restart → active).
|
||||
4. **Доки**: STATUS.md («Сделано (сессия кандидаты-фиксы 2026-09-14)» + «Итоговое состояние страницы кандидатов»), TODO.md (2 строки закрыты), WALKTHROUGH.md (эта секция), коммиты `acb49cc`, `0999424`, `e9f7c96`.
|
||||
|
||||
### Питфолы
|
||||
- `grep` с `f`-строками в шаблоне (`RedirectResponse(url=f"/candidates`) — спецсимволы ломали командную строку; использовать search_files (ripgrep) вместо shell-grep.
|
||||
- Пароль веба — /tmp/vesti_pass.txt (значение не фиксировать в доках).
|
||||
|
||||
### Команды
|
||||
```bash
|
||||
cd /opt/vesti
|
||||
.venv/bin/python -m crawler ... # диагностика выборки: /tmp/diag_fetch_own.py
|
||||
# юнит-проверка контекста: .venv/bin/python /tmp/verify_context.py
|
||||
# живая проверка: bash /tmp/check_context.sh (логин + group_by=date + status=rejected)
|
||||
sudo systemctl restart vesti-web && systemctl is-active vesti-web # active
|
||||
```
|
||||
|
||||
### Итоговое состояние
|
||||
- Кандидаты = внешние `status='new'` (163), без своих/published/rejected.
|
||||
- После reject/approve/comment/reclassify/rewrite — возврат в ту же группировку с сохранением фильтров.
|
||||
- `?status=rejected` / `?status=published` — работают (4 и 5 внешних постов соответственно).
|
||||
- Тихие change: `candidates-only-external-new`, `keep-candidates-context` — задачи [x]; архивировать после подтверждения.
|
||||
|
||||
### Следующая сессия
|
||||
1. Проработать **candidates-mail-ui** (2 панели) — открытые вопросы см. предыдущая сессия.
|
||||
2. Архив `candidates-only-external-new`, `keep-candidates-context` + rich-repost-card + gotosocial-publisher (delta в specs).
|
||||
3. Пометить аккаунт vesti как **bot** (GtS 0.22.1, PATCH update_credentials bot:true / actor_type Service).
|
||||
4. Фан-аут направлений → каналы (в т.ч. fediverse-каналы).
|
||||
|
||||
## Сессия 2026-09-15 — LWN→tech, /crawlers + /sources, «Переписать» от имени автора, легенда бейджей
|
||||
|
||||
### 1. Двухфазный пайплайн (сбор → очередь → параллельная обработка)
|
||||
- `crawler/crawl_sources.py` — одиночный сбор всех источников (RSS+TG) в `posts` (status='new').
|
||||
- `crawler/rss_crawler.py` — RSS-краулер по sources.yaml (feed_url), сид state в `rss_state`.
|
||||
- `crawler/worker.py` — параллельный воркер: claim_post (LEFT JOIN sources → source_slug), 8 потоков, MAX_CONCURRENT_LLM=2 (SQLite-соединение ВНУТРИ work_loop — нельзя шарить между потоками), fan-out по направлениям.
|
||||
- Запуск: `.venv/bin/python -m crawler.worker --workers N --limit N`, `.venv/bin/python -m crawler.crawl_sources`.
|
||||
- `web/app.py` /crawlers — страница управления: очередь (pending), источники, кнопка «Запустить воркер» (POST /crawlers/run → subprocess). Фикс: в render() не передавался request=request → добавлен.
|
||||
|
||||
### 2. Правило источника LWN → tech (детерминированно, без LLM)
|
||||
- `classifier/keywords.py`: SOURCE_RULES = {"lwn": ["tech"]} + source_directions(slug); find_direction(text, source_slug)/find_directions_all(...) — правило источника имеет ПРИОРИТЕТ над словарём.
|
||||
- `classifier/classify.py classify_text(post, source_slug)`: при наличии правила → return сразу (method='source-rule'), LLM не тратится.
|
||||
- `crawler/worker.py`: claim_post достаёт source_slug через JOIN; старый CLI classifier.classify тоже JOIN sources.
|
||||
- Проверено: юнит + in-memory пайплайн (LWN → direction=tech, classified).
|
||||
|
||||
### 3. Страница управления источниками /sources (уже была готова)
|
||||
- `web/templates/sources.html` + CRUD в `web/app.py`: ➕ добавить (slug, name, url, crawler, feed_url, direction, priority, enabled), inline-переименование name, ⏸/▶ пауза (enabled), inline-select приоритета P0–P3, 🗑 удаление (каскад: posts.source_id=NULL, runs, rss_state — посты остаются). Валидация slug [a-z0-9_-].
|
||||
- Истина — sources.yaml, каждое изменение пишется в yaml+БД (upsert_source_yaml + sync_yaml_to_db_and_back) — краулеры НЕ затирают правки при sync.
|
||||
- В навбаре (base.html) есть пункт «Источники».
|
||||
|
||||
### 4. Change rewrite-as-author — «✍️ Переписать» публикует ОТ ИМЕНИ автора, поле редактируемо (skip_specs)
|
||||
- Баг 1: textarea пересказа была readonly (подпись обещала «редактируйте и копируйте»). → убрал readonly, добавил «💾 Сохранить пересказ» (POST /posts/{id}/rewrite-save).
|
||||
- Баг 2: make_card публиковал исходный text, игнорируя rewritten_text. → publisher/card.py: при непустом rewritten_text тело=пересказ, подпись «✍️ Дед в АйТи (@dedinit) · Источник: <url>» вместо «🔗 Оригинал» (от имени канала, с атрибуцией). Без пересказа — как раньше.
|
||||
- Проверено read-only на посте id=4 (LWN, есть rewritten_text): карточка начинается с пересказа, есть «Дед в АйТи», нет «🔗 Оригинал».
|
||||
|
||||
### 5. Легенда бейджей + тултипы кнопок + комментарий уходит с «Опубликовать»
|
||||
- candidates.html: под бейджами — details «Бейджи поста — что значит каждый цвет»: направление (серый), critical/high (зелёный/синий — важность), interest N (жёлтый, 1–5), ⭐ СВОЙ, ↩︎ форвард.
|
||||
- Тултипы (title) на кнопках: ✅ Опубликовать, 🤖 Обработать моделью (qwen3:8b), ✍️ Переписать, 💾 Сохранить комментарий, 🚫 Отклонить.
|
||||
- Комментарий внутри approve-формы: textarea + «✅ Опубликовать» (submit) и «💾 Сохранить комментарий» (formaction → /comment, без JS). Потерять комментарий при публикации нельзя: он в той же форме. Reclassify/rewrite/reject — отдельные формы.
|
||||
- Рестарт: sudo systemctl restart vesti-web (uvicorn БЕЗ --reload, Jinja2 кеширует шаблоны — после правок шаблонов рестарт ОБЯЗАТЕЛЕН).
|
||||
|
||||
### Коммит в git (НЕ закоммичено — на проверку в след. сессии)
|
||||
- Изменено: classifier/*, crawler/{worker,crawl_sources,rss_crawler}.py (новые), publisher/card.py, sources/{sources.py,sources.yaml}, web/app.py, web/templates/{candidates,crawlers,sources,base}.html, config.py, requirements.txt, AGENT.MD, STATUS.md, TODO.md, .env.example.
|
||||
- Новые openspec-changes: crawler-queue, rss-crawler, sources-admin (архив), rewrite-as-author; спека specs/sources-admin/.
|
||||
- Пуш НЕ делался — закоммитить и запушить (gitverse истина, gitea зеркало).
|
||||
|
||||
## 2026-09-15 — Сессия: почтовый клиент для кандидатов + пайплайн «Отобранные»
|
||||
|
||||
Ключевой рефакторинг интерфейса кандидатов: «как в почтовом клиенте», затем введение стадии «Отобранные».
|
||||
|
||||
### 1. Кнопки ряда + фикс «Опубликовать» (коммиты 562d787, b542e9b)
|
||||
- Ряд кнопок [🤖 Обработать моделью][✍️ Переписать][🚫 Отклонить] — ОДИН flex-ряд НАД approve-формой (отдельные <form>, чтобы вёрстка не разъезжалась).
|
||||
- После 🚫 Отклонить (single/bulk/reject-old) — редирект на _cand_back(...) БЕЗ status="rejected" → остаёмся на кандидатах, в той же группировке (change reject-stay-candidates).
|
||||
- ✅ Опубликовать перенесён НИЖЕ поля комментария рядом с 💾 Сохранить комментарий (пользователь: «должна быть ниже поля комментария, рядом с кнопкой Сохранить комментарий»).
|
||||
- Правая панель: height:82vh (flex-колонка), «Оригинальный пост» — flex-grow+overflow-auto → занимает всё место до низа (не было пустоты).
|
||||
|
||||
### 2. Флаг прочитанности (коммит c09dff0, change candidates-read-flag)
|
||||
- Колонка posts.is_read (миграция ИДЕМПОТЕНТНО в _db() web/app.py — при каждом подключении; schema.sql тоже обновлён, но db/ в .gitignore, в git не идёт).
|
||||
- Открытие поста (GET /candidates?selected=N) → UPDATE is_read=1.
|
||||
- Непрочитанные карточки — fw-bold, выбранный пост — класс active-row (заливка #dcecfc, base.html).
|
||||
- Дефолт group_by="date" во всех трёх эндпоинтах (GET /candidates, POST select, POST bulk).
|
||||
- Проверка: /tmp/check_read*.py (TestClient), /tmp/check_live_read.py (живой HTTP 127.0.0.1:8400; нужен логин через форму /login с ADMIN_PASSWORD из .env).
|
||||
|
||||
### 3. Пайплайн «Кандидаты → Отобранные → Опубликованные» (коммит ae8af8d, change selected-list)
|
||||
- Новый статус posts.status='selected' (БД-миграции не нужно — status TEXT без CHECK).
|
||||
- Кандидаты (new): ТОЛЬКО отбор. Карточка: «⭐ Отобрать» (POST /posts/{id}/select → status new→selected). Bulk: единственная кнопка «⭐ Отобрать» (action=select).
|
||||
- /selected — отдельный двухпанельный экран (web/templates/selected.html, GET /selected в app.py): фильтруется по status='selected'. ВСЯ обработка здесь:
|
||||
- [🤖 Обработать моделью] POST /posts/{id}/reclassify (только по кнопке — токены на кандидатов НЕ тратятся; bulk process),
|
||||
- [✍️ Переписать] POST /posts/{id}/rewrite → кладёт черновик в rewritten_text; bulk rewrite (добавлен),
|
||||
- [🚫 Отклонить] POST /posts/{id}/reject (→ status rejected, остаёмся в selected — список пустеет, это норма),
|
||||
- комментарий + [✅ Опубликовать] + [💾 Сохранить комментарий].
|
||||
- Поле «✍️ Мой пост» (textarea rewritten_text) — ВСЕГДА видимо (в отличие от старого `{% if rewritten_text %}`), рядом (col-md-6) с «Оригинальный пост» (col-md-6). Публикация: publisher/card.py берёт body = rewritten_text или text — т.е. публикуется СВОЙ текст, а не репост.
|
||||
- _cand_back(group_by, direction, own, q, src='candidates', **extra) — src выбирает список для редиректа (candidates|selected). Во все формы добавлен скрытый <input name="src" value="selected|candidates">, во все обработчики — Form("src", default "candidates").
|
||||
- candidates_bulk: параметр src, action select/rewrite добавлены, редиректы → /{src}.
|
||||
- base.html: навбар «⭐ Отобранные» (/selected) между «Кандидаты» и «Опубликовано».
|
||||
|
||||
### 4. Проверка (важно для воспроизводимости)
|
||||
- Тесты: /tmp/test_selected.py, /tmp/test_select_flow.py, /tmp/test_rewrite.py, /tmp/check_final.py — через TestClient(fastapi): логин POST /login с ADMIN_PASSWORD (.env) → cookie; select single/bulk → /selected (200), кнопки на месте; rewrite — реальный вызов Ollama (qwen), rewritten_text записан.
|
||||
- Живой HTTP: /candidates без авторизации → 303 (в проде env VESTI_WEB_SESSION задан юнитом; в TestClient env пуст → _session_ok возвращает True при отсутствии cookie — тестовая дыра, в проде не воспроизводима).
|
||||
- Откат тестовых мутаций: 85/109 → status='new', rewritten_text=NULL.
|
||||
- Рестарт: sudo systemctl daemon-reload && sudo -S -p '' systemctl restart vesti-web (юнит менялся ранее; Jinja2 кеширует шаблоны — рестарт ОБЯЗАТЕЛЕН).
|
||||
|
||||
### 5. ВНИМАНИЕ — незакоммиченные наработки прошлой сессии (НЕ МОИ)
|
||||
В git status на момент ae8af8d остаются НЕЗАКОММИЧЕННЫМИ/НЕЗАПУШЕННЫМИ изменения прошлых сессий (запись «Коммит в git (НЕ закоммичено…)» выше):
|
||||
- crawler/{crawl_sources,rss_crawler,worker}.py (??), openspec/changes/{crawler-queue,rss-crawler,rewrite-as-author}/ (??), openspec/changes/archive/2026-09-15-sources-admin/, openspec/specs/sources-admin/, web/templates/{crawlers,sources}.html (??), classifier/classify.py, classifier/keywords.py, publisher/card.py, sources/{sources.py,sources.yaml}, config.py, requirements.txt, AGENT.MD, .env.example, WALKTHROUGH.md, TODO.md (M).
|
||||
- СЛЕДУЮЩЕЙ СЕССИИ: проверить, закоммитить и запушить на gitverse (git add -A, commit, push), НЕ трогая db/ (в .gitignore).
|
||||
|
||||
## 2026-09-17 — Сессия: фикс чекбоксов bulk-выбора (change keep-bulk-checkboxes)
|
||||
|
||||
### Проблема
|
||||
При массовом отборе кандидатов пользователь отмечает несколько постов чекбоксами, затем открывает один (ссылка ?selected=N) — страница перезагружается, checked ставится только текущему посту, остальные отметки теряются. Нужно, чтобы отметки сохранялись при навигации.
|
||||
|
||||
### Решение (через OpenSpec)
|
||||
- change `keep-bulk-checkboxes` (proposal/design/tasks/specs/candidates-bulk-selection), валиден с первого раза.
|
||||
- Механизм: состояние чекбоксов (Set id) хранится в sessionStorage браузера по ключам `vesti_bulk_candidates` (страница /candidates) и `vesti_bulk_selected` (страница /selected); при загрузке страницы скрипт восстанавливает checked и пересчитывает badge `sel-count`; при submit bulk-формы отметки очищаются (чтобы не «протухали»).
|
||||
- Изменены только web/templates/candidates.html и web/templates/selected.html. Сервер/БД не трогали; рестарт не нужен (Jinja2 перечитывает шаблоны; при сомнении — sudo systemctl restart vesti-web).
|
||||
|
||||
### Баг, найденный в процессе (важно!)
|
||||
- Чекбоксы лежат ВНЕ `<form id="bulk-form">` — связаны атрибутом `form="bulk-form"`. Исходный селектор `#bulk-form input[name="ids"]` возвращал 0 элементов → скрипт не цеплял чекбоксы вовсе (счётчик работал только потому, что чекбокс открытого поста сервер помечал checked атрибутом). Заменено на глобальный `document.querySelectorAll('input[name="ids"]')`.
|
||||
|
||||
### Проверка (браузерный E2E, Camofox)
|
||||
- Логин через curl (ADMIN_PASSWORD из .env) → cookie, подстановка в браузер.
|
||||
- /candidates: отметил 24 и 52 → sessionStorage `["24","52"]`, badge 3 (12 текущий + 24 + 52). Открыл пост 52 → 24 и 52 остались отмеченными, badge 2.
|
||||
- /selected: отметил 109 и 110 → sessionStorage `["109","110"]`, badge 3 (85 + 109 + 110). Открыл пост 110 → 109 и 110 отмечены, badge 2.
|
||||
- Изоляция: ключи candidates/selected независимы (cand `["24","52"]` не влияет на selected).
|
||||
|
||||
### Коммит
|
||||
- `c8fe5f7` — fix(web): keep bulk checkbox selection across post navigation. Запушен в gitverse (8777a0c..c8fe5f7).
|
||||
- В рабочем дереве остаются чужие незакоммиченные наработки (crawler/*, classifier/*, sources/* и др.) — НЕ трогал.
|
||||
|
||||
## 2026-09-18 — Сессия: перевод поста на русский в карточках (change post-translate)
|
||||
|
||||
### Задача пользователя
|
||||
«Добавить перевод в карточку поста (и кандидата, и отобранного). Метка "Оригинал" — сделать закладкой,
|
||||
рядом кнопка "Перевести", создающая вторую закладку "Перевод" с переводом на русский. Справится ли локальная модель?»
|
||||
|
||||
### Решение (через OpenSpec)
|
||||
- change `post-translate` (proposal/design/tasks/specs/post-translate), `openspec validate` — чисто.
|
||||
- Ответ на вопрос пользователя: ДА, локальная модель справляется (qwen3:8b-nothink на Ollama).
|
||||
|
||||
### Код
|
||||
1. **Миграция**: `_migrate()` в web/app.py — идемпотентный `ALTER TABLE posts ADD COLUMN translated_text TEXT`
|
||||
(проверка через `PRAGMA table_info(posts)` перед ALTER; при старте веба).
|
||||
2. **Helper** `_translate_text(text)` — прямой запрос к Ollama `/v1/chat/completions` (model qwen3:8b-nothink,
|
||||
temperature 0.2, `think: False`, timeout из classifier TIMEOUT). Лимит `TRANSLATE_MAX_CHARS = 6000`.
|
||||
- Промпт: «Переведи новость на русский. Сохрани смысл, факты, имена, числа и ссылки. Верни ТОЛЬКО перевод…».
|
||||
3. **Роут** `POST /posts/{post_id}/translate` — после post_rewrite; если `translated_text` уже заполнен —
|
||||
просто редирект (без вызова модели!); иначе переводит, `UPDATE`, редирект на `_cand_back(..., selected=post_id)`.
|
||||
Ошибка модели → редирект с `error=translate:{e}` (рендерится как alert).
|
||||
4. **Шаблоны** candidates.html и selected.html: метка «Оригинальный пост» → закладки
|
||||
(nav-pills + tab-pane, vanilla JS в scripts-блоке — БЕЗ CDN/Bootstrap JS, как остальной проект).
|
||||
- Нет перевода: одна закладка «Оригинал» + кнопка «🌐 Перевести».
|
||||
- Есть перевод: закладки «Оригинал» и «Перевод» (активна «Перевод»), кнопка скрыта.
|
||||
- candidates.html: `id="tab-orig-c"/"tab-tr-c"`; selected.html: `tab-orig-s"/"tab-tr-s"`, перевод
|
||||
в правой колонке (col-md-6) с той же логикой.
|
||||
|
||||
### Питфол (важно!)
|
||||
- `call_ollama` из classifier/classify.py НЕ подходит для перевода: она жёстко парсит ответ модели как JSON
|
||||
(это для классификации). Перевод — обычный текст → JSON-парсинг ломался (пустой результат).
|
||||
Решение: прямой `httpx.post` к `{OLLAMA_URL}/v1/chat/completions` в `_translate_text`.
|
||||
|
||||
### Проверка
|
||||
- Модель жива: `curl -s -m 8 http://127.0.0.1:11434/v1/chat/completions -d '{"model":"qwen3:8b-nothink",...}'` → «Привет, мир».
|
||||
- `_translate_text()` на реальном тексте: «LWN.net сообщает о последнем выпуске ядра Linux 6.12…».
|
||||
- E2E TestClient: логин (пароль из .env) → POST /posts/123/translate → translated_text в БД (Kali Purple, 454 символа,
|
||||
markdown и ссылки сохранены).
|
||||
- Повторный клик: подмена `_translate_text` показала 0 вызовов модели (translated_text уже есть) ✓.
|
||||
- Живой сервис: `sudo systemctl restart vesti-web` → active; страницы отдают закладки (curl с cookie из /login).
|
||||
|
||||
### Коммит
|
||||
- `4a166fa` — feat(web): перевод поста на русский локальной моделью (openspec post-translate). Запушен
|
||||
на gitverse (`745680a..4a166fa main -> main`, ключ .ssh_box/gitverse).
|
||||
- 9 файлов: web/app.py, candidates.html, selected.html, STATUS.md, openspec/changes/post-translate/* (5).
|
||||
- Чужие незакоммиченные наработки (extract-post-sources, manual-direction, crawler/*, classifier/* и др.)
|
||||
НЕ трогал — коммитился только свой change (git add точечный).
|
||||
|
||||
## 2026-09-18 — Закрытие сессии
|
||||
- Задача post-translate полностью закрыта (коммит + пуш). Обновлены STATUS.md, TODO.md, WALKTHROUGH.md.
|
||||
- Открытые задачи проекта — в TODO.md (метрики-инсайты, fan-out направлений, веб-скедулинг RSS, извлечение источников).
|
||||
|
||||
@@ -15,15 +15,15 @@ mkdir -p ${BACKUP_DIR}
|
||||
|
||||
echo "🔄 [$(date)] Начинаем резервное копирование VESTI..."
|
||||
|
||||
# Проверяем, примонтирован ли Яндекс.Диск
|
||||
if ! mountpoint -q ${YADISK_MOUNT}; then
|
||||
echo " ❌ ОШИБКА: Яндекс.Диск не примонтирован!"
|
||||
exit 1
|
||||
# Проверяем, примонтирован ли Яндекс.Диск (не падаем, если нет — копия локально)
|
||||
if timeout 10 mountpoint -q ${YADISK_MOUNT}; then
|
||||
YADISK_UP=1
|
||||
mkdir -p ${YADISK_TARGET} 2>/dev/null
|
||||
else
|
||||
echo " ⚠️ Яндекс.Диск не примонтирован — копия только локально"
|
||||
YADISK_UP=0
|
||||
fi
|
||||
|
||||
# Создаём папку для бэкапов на Яндекс.Диске (если её нет)
|
||||
mkdir -p ${YADISK_TARGET} 2>/dev/null
|
||||
|
||||
# 1. Бэкап проекта одним архивом
|
||||
ARCHIVE="${BACKUP_DIR}/vesti_${DATE}.tar.gz"
|
||||
echo " → Архив проекта..."
|
||||
@@ -31,9 +31,14 @@ tar czf ${ARCHIVE} -C ${PROJECT_DIR} ${INCLUDE} 2>/dev/null
|
||||
|
||||
if [ -s "${ARCHIVE}" ]; then
|
||||
echo " ✅ Архив: $(du -h ${ARCHIVE} | cut -f1)"
|
||||
echo " ☁️ Копирование на Яндекс.Диск..."
|
||||
cp ${ARCHIVE} ${YADISK_TARGET}/
|
||||
echo " ✅ скопирован на ЯД (${YADISK_TARGET}/)"
|
||||
# Копирование на ЯД только если архив <= 2GiB (больше — вешает davfs/systemd1)
|
||||
archive_size=$(stat -c %s "${ARCHIVE}" 2>/dev/null || echo 0)
|
||||
if [ "${YADISK_UP}" = "1" ] && [ "${archive_size}" -le $((2 * 1024 * 1024 * 1024)) ]; then
|
||||
echo " ☁️ Копирование на Яндекс.Диск..."
|
||||
timeout 3600 cp ${ARCHIVE} ${YADISK_TARGET}/ && echo " ✅ скопирован на ЯД (${YADISK_TARGET}/)"
|
||||
else
|
||||
echo " 📦 архив ${archive_size}B — НЕ копирую на ЯД (>2GiB или ЯД офлайн), остаётся локально"
|
||||
fi
|
||||
else
|
||||
echo " ❌ Ошибка создания архива!"
|
||||
fi
|
||||
@@ -42,8 +47,10 @@ fi
|
||||
# Локально: оставляем 7 дней
|
||||
find ${BACKUP_DIR} -type f -name "vesti_*" -mtime +7 -delete 2>/dev/null
|
||||
|
||||
# На Яндекс.Диске: оставляем 30 дней
|
||||
find ${YADISK_TARGET} -type f -name "vesti_*" -mtime +30 -delete 2>/dev/null
|
||||
# На Яндекс.Диске: оставляем 30 дней (только если смонтирован)
|
||||
if [ "${YADISK_UP}" = "1" ]; then
|
||||
timeout 60 find ${YADISK_TARGET} -type f -name "vesti_*" -mtime +30 -delete 2>/dev/null
|
||||
fi
|
||||
|
||||
echo "✅ [$(date)] Резервное копирование завершено!"
|
||||
|
||||
|
||||
+22
-3
@@ -24,16 +24,33 @@ def classify_text(post: dict) -> dict:
|
||||
пробуем LLM-уточнение (relevance/interest/summary). При недоступности LLM —
|
||||
фолбэк: direction из словаря, relevance=low, interest=1, classified=False.
|
||||
|
||||
Если у поста задан source_slug с правилом SOURCE_RULES (например, lwn → tech) —
|
||||
направление принудительное, LLM не вызывается (детерминированно, быстро).
|
||||
|
||||
СВОЙ контент (is_own=1): «сильный кандидат» — при словарном попадании
|
||||
relevance=critical, classified=True даже без LLM (фолбэк 'dict-own').
|
||||
Если словарь не дал направление — классификации нет, пост не теряется.
|
||||
|
||||
post: {"text": str, "views": int, "reactions_total": int, "is_own": int, ...}
|
||||
post: {"text": str, "views": int, "reactions_total": int, "is_own": int, "source_slug": str|None, ...}
|
||||
"""
|
||||
text = (post.get("text") or "").strip()
|
||||
views = int(post.get("views") or 0)
|
||||
reactions = int(post.get("reactions_total") or 0)
|
||||
is_own = int(post.get("is_own") or 0) == 1
|
||||
source_slug = post.get("source_slug")
|
||||
|
||||
# правило источника (SOURCE_RULES): принудительное направление, без LLM
|
||||
from classifier.keywords import source_directions
|
||||
forced = source_directions(source_slug)
|
||||
if forced:
|
||||
return {
|
||||
"direction": forced[0],
|
||||
"relevance": "high",
|
||||
"interest": 3,
|
||||
"summary": "",
|
||||
"classified": True,
|
||||
"method": "source-rule",
|
||||
}
|
||||
|
||||
direction = find_direction(text)
|
||||
if not direction:
|
||||
@@ -170,7 +187,9 @@ def classify_posts_in_db(db_path: str, limit: int = 200, direction: str | None =
|
||||
where += f" AND direction IS NULL" # не классифицированы в этом направлении
|
||||
|
||||
rows = cur.execute(
|
||||
f"SELECT id, text, views, reactions_total, is_own FROM posts {where} ORDER BY is_own DESC, id LIMIT ?",
|
||||
f"SELECT p.id, p.text, p.views, p.reactions_total, p.is_own, s.slug AS source_slug "
|
||||
f"FROM posts p LEFT JOIN sources s ON s.id = p.source_id {where} "
|
||||
f"ORDER BY p.is_own DESC, p.id LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
|
||||
@@ -188,7 +207,7 @@ def classify_posts_in_db(db_path: str, limit: int = 200, direction: str | None =
|
||||
)
|
||||
# мультинаправления: все словарные попадания → classifications (для fan-out)
|
||||
from classifier.keywords import find_directions_all
|
||||
dirs_all = find_directions_all(row["text"] or "")
|
||||
dirs_all = find_directions_all(row["text"] or "", row["source_slug"])
|
||||
if res["direction"] and res["direction"] not in dirs_all:
|
||||
dirs_all.append(res["direction"])
|
||||
for d in dirs_all:
|
||||
|
||||
+29
-4
@@ -93,9 +93,28 @@ import re
|
||||
# Направление по умолчанию, если ничего не подошло
|
||||
DEFAULT_DIRECTION = "linux" # прототип: все посты про linux окружение
|
||||
|
||||
# Правила по источнику (slug в sources.yaml): принудительное направление
|
||||
# для всего контента источника, независимо от словаря/LLM.
|
||||
# LWN — технологическое СМИ: весь контент → tech.
|
||||
SOURCE_RULES: dict[str, list[str]] = {
|
||||
"lwn": ["tech"],
|
||||
}
|
||||
|
||||
def find_directions_all(text: str) -> list[str]:
|
||||
"""Возвращает ВСЕ направления, которым соответствует текст (для мультинаправлений/fan-out)."""
|
||||
|
||||
def source_directions(source_slug: str | None) -> list[str]:
|
||||
"""Направления, принудительно назначенные источнику (SOURCE_RULES)."""
|
||||
return list(SOURCE_RULES.get(source_slug or "", []))
|
||||
|
||||
|
||||
def find_directions_all(text: str, source_slug: str | None = None) -> list[str]:
|
||||
"""Возвращает ВСЕ направления, которым соответствует текст (для мультинаправлений/fan-out).
|
||||
|
||||
Если для источника задано правило SOURCE_RULES — возвращает его направления
|
||||
(принудительно), иначе — словарные попадания.
|
||||
"""
|
||||
forced = source_directions(source_slug)
|
||||
if forced:
|
||||
return forced
|
||||
text_low = (text or "").lower()
|
||||
hits = set()
|
||||
for direction, cfg in DIRECTIONS.items():
|
||||
@@ -112,8 +131,14 @@ def find_directions_all(text: str) -> list[str]:
|
||||
return list(hits)
|
||||
|
||||
|
||||
def find_direction(text: str) -> str | None:
|
||||
"""Возвращает направление, если текст совпал со словарём (иначе None)."""
|
||||
def find_direction(text: str, source_slug: str | None = None) -> str | None:
|
||||
"""Возвращает направление: правило источника (SOURCE_RULES) или словарное.
|
||||
|
||||
Принудительное правило источника имеет приоритет над словарём.
|
||||
"""
|
||||
forced = source_directions(source_slug)
|
||||
if forced:
|
||||
return forced[0]
|
||||
dirs = find_directions_all(text)
|
||||
if dirs:
|
||||
return dirs[0]
|
||||
|
||||
@@ -26,6 +26,10 @@ ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "change_me")
|
||||
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://127.0.0.1:11434")
|
||||
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "qwen3:8b-nothink")
|
||||
|
||||
# RSS-краулер: честный User-Agent (некоторые издатели режут дефолтные), таймаут
|
||||
RSS_USER_AGENT = os.getenv("RSS_USER_AGENT", "vesti-rss/0.1 (+https://vesti.nixg.ru)")
|
||||
RSS_DEFAULT_TIMEOUT = float(os.getenv("RSS_DEFAULT_TIMEOUT", "20"))
|
||||
|
||||
# Направления (категории)
|
||||
DIRECTIONS = ["tech", "politics", "games", "electronics", "llm", "linux"]
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# VESTI — фаза 1: сбор кандидатов с источников в очередь (posts).
|
||||
# Запускает telegram_crawler и rss_crawler по включённым источникам;
|
||||
# классификация НЕ выполняется — новые посты уходят в очередь (classified=0),
|
||||
# которую разбирает crawler.worker (фаза 3, параллельно).
|
||||
#
|
||||
# Запуск: python -m crawler.crawl_sources --all
|
||||
# python -m crawler.crawl_sources --crawler rss # только RSS
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
BASE = Path(__file__).resolve().parent.parent
|
||||
PY = str(BASE / ".venv" / "bin" / "python")
|
||||
|
||||
|
||||
def run_crawler(mod: str, extra: list[str] | None = None) -> int:
|
||||
"""Запускает модуль-краулер в подпроцессе. Возвращает exit code."""
|
||||
cmd = [PY, "-m", mod] + (extra or [])
|
||||
print(f"--- {mod} {' '.join(extra or [])} ---", flush=True)
|
||||
return subprocess.run(cmd, cwd=str(BASE)).returncode
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="VESTI фаза 1: сбор кандидатов с источников в очередь")
|
||||
ap.add_argument("--all", action="store_true", help="сбор со всех включённых краулеров")
|
||||
ap.add_argument("--crawler", choices=["telegram", "rss"], help="только один тип краулера")
|
||||
args = ap.parse_args()
|
||||
|
||||
if not args.all and not args.crawler:
|
||||
ap.print_help()
|
||||
sys.exit(2)
|
||||
|
||||
rc = 0
|
||||
if args.all or args.crawler == "telegram":
|
||||
rc |= run_crawler("crawler.telegram_crawler", ["--all"])
|
||||
if args.all or args.crawler == "rss":
|
||||
rc |= run_crawler("crawler.rss_crawler", ["--all"])
|
||||
sys.exit(rc)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,362 @@
|
||||
# VESTI — RSS/Atom-краулер (feedparser + httpx).
|
||||
# Читает источники из sources.yaml (crawler: rss), условные GET (etag/modified),
|
||||
# нормализует URL (UTM/якоря), дотягивает полный текст (trafilatura) для summary-only,
|
||||
# пишет посты в posts (sha256-дедуп) и состояние ленты в rss_state.
|
||||
#
|
||||
# Запуск:
|
||||
# python -m crawler.rss_crawler --all # все включённые rss-источники
|
||||
# python -m crawler.rss_crawler --direction linux
|
||||
# python -m crawler.rss_crawler --source lwn
|
||||
# python -m crawler.rss_crawler --source lwn --dry-run # печать без записи в БД
|
||||
import argparse
|
||||
import hashlib
|
||||
import html as html_mod
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from config import RSS_DEFAULT_TIMEOUT, RSS_USER_AGENT, ensure_dirs
|
||||
from db.db import db
|
||||
from sources.sources import (
|
||||
get_enabled_sources, get_source, load_sources_yaml, sync_sources_to_db,
|
||||
start_run, finish_run,
|
||||
)
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
|
||||
log = logging.getLogger("vesti.rss")
|
||||
|
||||
MAX_ITEMS = 25 # максимум записей ленты за запуск (остальные — следующими запусками)
|
||||
RATE_LIMIT_SEC = 1.5 # пауза между источниками (вежливый опрос)
|
||||
MIN_SUMMARY_LEN = 220 # если выжимка короче — пробуем дотянуть полный текст
|
||||
ERROR_DEAD_THRESHOLD = 5 # N ошибок подряд → status='dead' (фид-здоровье)
|
||||
|
||||
# Параметры запроса, которые режут из URL (трекинг/мусор)
|
||||
UTM_PARAMS = {
|
||||
"utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content",
|
||||
"fbclid", "gclid", "ref", "ref_src", "mc_cid", "mc_eid",
|
||||
}
|
||||
|
||||
|
||||
def normalize_url(url):
|
||||
"""Чистит URL от UTM-параметров, fbclid/gclid/ref и якоря -> canonical_url.
|
||||
|
||||
Возвращает None для мусорных ссылок (без схемы/хоста).
|
||||
"""
|
||||
if not url:
|
||||
return None
|
||||
url = url.strip()
|
||||
if not url.startswith(("http://", "https://")):
|
||||
return None
|
||||
u = urlparse(url)
|
||||
if not u.netloc:
|
||||
return None
|
||||
q = [(k, v) for k, v in parse_qsl(u.query, keep_blank_values=True)
|
||||
if k.lower() not in UTM_PARAMS]
|
||||
return urlunparse(u._replace(query=urlencode(q), fragment=""))
|
||||
|
||||
|
||||
def sha256_text(text: str) -> str:
|
||||
return hashlib.sha256((text or "").encode("utf-8", "ignore")).hexdigest()
|
||||
|
||||
|
||||
# Блочные теги, вокруг которых сохраняем переносы (абзацы/списки/заголовки/цитаты)
|
||||
_BLOCK_TAGS = [
|
||||
"p", "div", "li", "h1", "h2", "h3", "h4", "h5", "h6",
|
||||
"blockquote", "pre", "tr", "section", "article",
|
||||
]
|
||||
|
||||
# Нормализация текста: сохраняем структуру абзацев (\n\n), убираем мусор.
|
||||
# НЕ схлопываем всё в одну строку — иначе новость в предпросмотре — сплошной текст.
|
||||
def normalize_text(text):
|
||||
"""Чистит текст: пробелы по краям строк, 3+ переноса → 2, без изменения слов."""
|
||||
if not text:
|
||||
return ""
|
||||
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
||||
text = re.sub(r"[ \t]+\n", "\n", text) # пробелы перед концом строки
|
||||
text = re.sub(r"\n[ \t]+", "\n", text) # пробелы после начала строки
|
||||
text = re.sub(r"\n{3,}", "\n\n", text) # не больше одной пустой строки
|
||||
# пробелы по краям каждой строки
|
||||
text = "\n".join(l.strip() for l in text.split("\n"))
|
||||
text = re.sub(r"\n{3,}", "\n\n", text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def html_to_text(html_str):
|
||||
"""Раскрывает HTML (из ленты) в читаемый text с сохранением абзацев/списков.
|
||||
|
||||
HTML-теги разметки (b/i/a/...) вырезаются, их содержимое остаётся текстом;
|
||||
блочные теги (p/li/h1-6/blockquote/pre) дают переносы строк, чтобы текст
|
||||
не превращался в сплошную кашу.
|
||||
"""
|
||||
from selectolax.parser import HTMLParser
|
||||
if not html_str:
|
||||
return ""
|
||||
try:
|
||||
tree = HTMLParser(html_str)
|
||||
tree.strip_tags(["script", "style", "noscript"])
|
||||
if tree.body is None:
|
||||
return ""
|
||||
for node in tree.css(", ".join(_BLOCK_TAGS)):
|
||||
node.insert_before("\n\n")
|
||||
text = tree.body.text()
|
||||
return normalize_text(text)
|
||||
except Exception as e:
|
||||
log.debug("html_to_text failed: %s", e)
|
||||
return re.sub(r"<[^>]+>", " ", html_mod.unescape(html_str)).strip()
|
||||
|
||||
|
||||
def extract_full_text(url, timeout=15):
|
||||
"""Полный текст страницы через trafilatura. Возвращает str или None (не падает)."""
|
||||
import httpx
|
||||
try:
|
||||
r = httpx.get(url, headers={"User-Agent": RSS_USER_AGENT},
|
||||
timeout=timeout, follow_redirects=True)
|
||||
if r.status_code != 200:
|
||||
return None
|
||||
import trafilatura
|
||||
txt = trafilatura.extract(
|
||||
r.text,
|
||||
include_comments=False,
|
||||
include_tables=False,
|
||||
favor_precision=True,
|
||||
# include_formatting=True сохраняет абзацы (\\n\\n) — иначе текст
|
||||
# схлопывается в сплошную строку и новость нечитаема
|
||||
include_formatting=True,
|
||||
)
|
||||
return (txt or "").strip() or None
|
||||
except Exception as e:
|
||||
log.debug("full text %s failed: %s", url, e)
|
||||
return None
|
||||
|
||||
|
||||
def entry_published_at(entry):
|
||||
"""published_parsed/updated_parsed -> ISO-строка; None если нет даты."""
|
||||
for key in ("published_parsed", "updated_parsed"):
|
||||
st = getattr(entry, key, None)
|
||||
if st:
|
||||
try:
|
||||
import datetime
|
||||
return datetime.datetime(*st[:6], tzinfo=datetime.timezone.utc).isoformat()
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def parse_entries(feed, source, dry_run=False):
|
||||
"""Превращает записи ленты feedparser в список dict постов.
|
||||
|
||||
returns (posts, last_build_date)
|
||||
"""
|
||||
posts = []
|
||||
for entry in feed.entries[:MAX_ITEMS]:
|
||||
link = getattr(entry, "link", None) or ""
|
||||
canonical = normalize_url(link)
|
||||
if not canonical:
|
||||
continue
|
||||
title = getattr(entry, "title", "") or ""
|
||||
# полный текст из ленты (content[].value), иначе summary
|
||||
content_parts = [c.get("value", "") for c in (getattr(entry, "content", None) or [])]
|
||||
raw_html = content_parts[0] if content_parts else ""
|
||||
summary_html = getattr(entry, "summary", "") or ""
|
||||
text = html_to_text(raw_html or summary_html)
|
||||
# summary-only лента: пробуем дотянуть полный текст
|
||||
if (not raw_html or len(text) < MIN_SUMMARY_LEN) and canonical:
|
||||
full = extract_full_text(canonical)
|
||||
if full:
|
||||
text = full
|
||||
if not text:
|
||||
text = title # последний рубеж: заголовок
|
||||
text = normalize_text(text)
|
||||
posts.append({
|
||||
"url": canonical,
|
||||
"title": title,
|
||||
"text": text,
|
||||
"published_at": entry_published_at(entry),
|
||||
"author": getattr(entry, "author", None) or None,
|
||||
})
|
||||
last_build = None
|
||||
fb = getattr(feed, "last_build_date_parsed", None) or getattr(feed, "published_parsed", None)
|
||||
if fb:
|
||||
try:
|
||||
import datetime
|
||||
last_build = datetime.datetime(*fb[:6], tzinfo=datetime.timezone.utc).isoformat()
|
||||
except Exception:
|
||||
last_build = None
|
||||
return posts, last_build
|
||||
|
||||
|
||||
def store_rss_posts(conn, source_id, posts):
|
||||
"""Вставка RSS-постов в posts с дедупом по sha256(text) и url.
|
||||
|
||||
Возвращает количество НОВЫХ. Повторная вставка той же статьи (даже с другим
|
||||
URL в ленте) не создаёт второй строки — sha256 от текста.
|
||||
"""
|
||||
new = 0
|
||||
for p in posts:
|
||||
sha = sha256_text(p["text"])
|
||||
dup = conn.execute(
|
||||
"SELECT id FROM posts WHERE sha256=? OR url=?",
|
||||
(sha, p["url"]),
|
||||
).fetchone()
|
||||
if dup:
|
||||
continue
|
||||
conn.execute(
|
||||
"""INSERT INTO posts
|
||||
(sha256, source_id, url, text, content_type, published_at, status, is_own, is_own_canonical)
|
||||
VALUES (?,?,?,?, 'text', ?, 'new', 0, 0)""",
|
||||
(sha, source_id, p["url"], p["text"], p["published_at"]),
|
||||
)
|
||||
new += 1
|
||||
return new
|
||||
|
||||
|
||||
def run_source(source, dry_run=False):
|
||||
"""Краулинг одного RSS-источника: условный GET, парсинг, запись, состояние."""
|
||||
slug = source["slug"]
|
||||
feed_url = source.get("feed_url")
|
||||
if not feed_url:
|
||||
log.error("SKIP %s: нет feed_url (crawler=rss, а лента не задана)", slug)
|
||||
return
|
||||
run_id = start_run(source["id"], f"rss:{slug}", "cron")
|
||||
t0 = time.monotonic()
|
||||
|
||||
try:
|
||||
import httpx
|
||||
# --- состояние ленты ---
|
||||
with db() as conn:
|
||||
st = conn.execute("SELECT * FROM rss_state WHERE slug=?", (slug,)).fetchone()
|
||||
state = dict(st) if st else {}
|
||||
|
||||
headers = {"User-Agent": RSS_USER_AGENT, "Accept": "application/rss+xml, application/atom+xml, application/xml;q=0.9, */*;q=0.8"}
|
||||
if state.get("etag"):
|
||||
headers["If-None-Match"] = state["etag"]
|
||||
if state.get("modified"):
|
||||
headers["If-Modified-Since"] = state["modified"]
|
||||
|
||||
resp = httpx.get(feed_url, headers=headers, timeout=RSS_DEFAULT_TIMEOUT,
|
||||
follow_redirects=True)
|
||||
# вежливый ретрай на 429/503 (LWN троттлит частые опросы)
|
||||
for attempt in range(3):
|
||||
if resp.status_code in (429, 503):
|
||||
retry_after = 0
|
||||
ra = resp.headers.get("retry-after")
|
||||
if ra:
|
||||
try: retry_after = int(ra)
|
||||
except ValueError: retry_after = 10
|
||||
wait = max(retry_after, 5 * (attempt + 1))
|
||||
log.warning("%s: HTTP %d, retry %d/3 через %ds", slug, resp.status_code, attempt + 1, wait)
|
||||
time.sleep(wait)
|
||||
resp = httpx.get(feed_url, headers=headers, timeout=RSS_DEFAULT_TIMEOUT,
|
||||
follow_redirects=True)
|
||||
else:
|
||||
break
|
||||
if resp.status_code == 304:
|
||||
# лента не менялась — экономим (0 fetched/0 new, ok)
|
||||
duration = int((time.monotonic() - t0) * 1000)
|
||||
if not dry_run:
|
||||
finish_run(run_id, "ok", 0, 0, None, duration)
|
||||
log.info("OK %s: 304 (не менялась), %dms", slug, duration)
|
||||
return
|
||||
|
||||
if resp.status_code != 200:
|
||||
raise RuntimeError(f"HTTP {resp.status_code} for {feed_url}")
|
||||
|
||||
import feedparser
|
||||
feed = feedparser.parse(resp.content, response_headers=resp.headers)
|
||||
|
||||
if getattr(feed, "bozo", False) and not feed.entries:
|
||||
raise RuntimeError(f"bozo feed (не парсится): {getattr(feed, 'bozo_exception', '')}")
|
||||
|
||||
posts, last_build = parse_entries(feed, source, dry_run)
|
||||
|
||||
if dry_run:
|
||||
print(f"=== {slug} ({source['name']}) — dry_run, {len(posts)} записей ===")
|
||||
for p in posts:
|
||||
print(f" - {p['published_at'] or '????'} | {p['title'][:80]}")
|
||||
print(f" {p['url']}")
|
||||
return
|
||||
|
||||
with db() as conn:
|
||||
new = store_rss_posts(conn, source["id"], posts)
|
||||
new_etag = (resp.headers.get("etag") or "").strip()
|
||||
new_modified = (resp.headers.get("last-modified") or "").strip()
|
||||
conn.execute(
|
||||
"""INSERT INTO rss_state (slug, etag, modified, last_build_date, last_error, error_count, status, updated_at)
|
||||
VALUES (?,?,?,?, NULL, 0, 'alive', datetime('now'))
|
||||
ON CONFLICT(slug) DO UPDATE SET
|
||||
etag=excluded.etag, modified=excluded.modified,
|
||||
last_build_date=excluded.last_build_date,
|
||||
last_error=NULL, error_count=0, status='alive',
|
||||
updated_at=datetime('now')""",
|
||||
(slug, new_etag or state.get("etag"), new_modified or state.get("modified"), last_build),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE sources SET last_fetch=datetime('now'), status='alive', last_error=NULL WHERE id=?",
|
||||
(source["id"],),
|
||||
)
|
||||
duration = int((time.monotonic() - t0) * 1000)
|
||||
finish_run(run_id, "ok", len(posts), new, None, duration)
|
||||
log.info("OK %s: fetched=%d new=%d (%.1fs)", slug, len(posts), new, duration / 1000)
|
||||
|
||||
except Exception as e:
|
||||
duration = int((time.monotonic() - t0) * 1000)
|
||||
if not dry_run:
|
||||
with db() as conn:
|
||||
st = conn.execute("SELECT error_count FROM rss_state WHERE slug=?", (slug,)).fetchone()
|
||||
err_count = (st[0] if st else 0) + 1
|
||||
status = "dead" if err_count >= ERROR_DEAD_THRESHOLD else "alive"
|
||||
conn.execute(
|
||||
"""INSERT INTO rss_state (slug, last_error, error_count, status, updated_at)
|
||||
VALUES (?,?,?,?, datetime('now'))
|
||||
ON CONFLICT(slug) DO UPDATE SET
|
||||
last_error=excluded.last_error, error_count=excluded.error_count,
|
||||
status=excluded.status, updated_at=datetime('now')""",
|
||||
(slug, str(e)[:500], err_count, status),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE sources SET last_error=?, status=? WHERE id=?",
|
||||
(str(e)[:500], status, source["id"]),
|
||||
)
|
||||
finish_run(run_id, "error", 0, 0, str(e)[:500], duration)
|
||||
log.error("ERROR %s: %s", slug, e)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="VESTI RSS/Atom crawler")
|
||||
ap.add_argument("--source", help="slug источника (например lwn)")
|
||||
ap.add_argument("--direction", help="направление (например linux)")
|
||||
ap.add_argument("--all", action="store_true", help="все включённые rss-источники")
|
||||
ap.add_argument("--dry-run", action="store_true", help="печать записей без записи в БД")
|
||||
args = ap.parse_args()
|
||||
|
||||
ensure_dirs()
|
||||
data = load_sources_yaml()
|
||||
sync_sources_to_db(data)
|
||||
|
||||
if args.source:
|
||||
src = get_source(args.source)
|
||||
if not src:
|
||||
log.error("source %s not found", args.source)
|
||||
sys.exit(1)
|
||||
sources = [src]
|
||||
elif args.direction:
|
||||
sources = get_enabled_sources(crawler="rss", direction=args.direction)
|
||||
else:
|
||||
sources = get_enabled_sources(crawler="rss")
|
||||
|
||||
if not sources:
|
||||
log.info("no rss sources to crawl")
|
||||
return
|
||||
|
||||
for src in sources:
|
||||
run_source(src, dry_run=args.dry_run)
|
||||
time.sleep(RATE_LIMIT_SEC)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,170 @@
|
||||
# VESTI — воркер (фаза 3): параллельная обработка очереди кандидатов.
|
||||
# ThreadPoolExecutor(N): каждый воркер атомарно забирает пост из очереди
|
||||
# (posts WHERE classified=0), классифицирует (keywords → Ollama), пишет
|
||||
# direction/relevance/interest/summary, ставит classified=1. При сбое — возвращает
|
||||
# в очередь (classified=0). Очередь — сама таблица posts, отдельная таблица не нужна.
|
||||
#
|
||||
# Запуск: python -m crawler.worker --workers 8 --limit 200
|
||||
import argparse
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from config import DB_PATH, ensure_dirs
|
||||
from classifier.classify import classify_text, call_ollama # noqa: F401 (переиспользуем)
|
||||
|
||||
# Лимит одновременных LLM-запросов: локальная Ollama (qwen3:8b на CPU/GPU) держит
|
||||
# один слот — параллельные 8 запросов = каждый ждёт >CLASSIFY_TIMEOUT и падает.
|
||||
# 8 воркеров = 8 потоков, но к LLM пускаем максимум N (обычно 2) одновременных.
|
||||
LLM_SEM = threading.BoundedSemaphore(int(os.getenv("MAX_CONCURRENT_LLM", "2")))
|
||||
|
||||
|
||||
def claim_post(conn: sqlite3.Connection) -> sqlite3.Row | None:
|
||||
"""Атомарный claim поста из очереди.
|
||||
|
||||
BEGIN IMMEDIATE блокирует других воркеров на время claim; SELECT→UPDATE
|
||||
гарантирует, что строку заберёт ровно один воркер (classified: 0 → -1).
|
||||
Возвращает строку поста или None (очередь пуста).
|
||||
"""
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT p.id, p.text, p.views, p.reactions_total, p.is_own, s.slug AS source_slug "
|
||||
"FROM posts p LEFT JOIN sources s ON s.id = p.source_id "
|
||||
"WHERE p.classified IS NULL OR p.classified=0 "
|
||||
"ORDER BY p.is_own DESC, p.id LIMIT 1"
|
||||
).fetchone()
|
||||
if row:
|
||||
conn.execute("UPDATE posts SET classified=-1 WHERE id=?", (row["id"],))
|
||||
conn.commit()
|
||||
return row
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
|
||||
|
||||
def process_post(conn: sqlite3.Connection, post_id: int, text: str,
|
||||
views: int, reactions: int, is_own: int, source_slug: str | None = None) -> dict:
|
||||
"""Классифицирует пост и пишет результат. Возвращает result classify_text.
|
||||
|
||||
При исключении — пост возвращается в очередь (classified=0), НЕ теряется.
|
||||
Мультинаправления из словаря (find_directions_all) — в classifications.
|
||||
source_slug (правило SOURCE_RULES, напр. lwn→tech) — направление принудительное.
|
||||
"""
|
||||
res = classify_text({"text": text, "views": views,
|
||||
"reactions_total": reactions, "is_own": is_own,
|
||||
"source_slug": source_slug})
|
||||
conn.execute(
|
||||
"UPDATE posts SET direction=?, relevance=?, interest=?, summary=?, classified=? WHERE id=?",
|
||||
(res["direction"], res["relevance"], res["interest"], res["summary"],
|
||||
1 if res["classified"] else 0, post_id),
|
||||
)
|
||||
# мультинаправления: все словарные попадания → classifications (для fan-out)
|
||||
from classifier.keywords import find_directions_all
|
||||
dirs_all = find_directions_all(text or "", source_slug)
|
||||
if res["direction"] and res["direction"] not in dirs_all:
|
||||
dirs_all.append(res["direction"])
|
||||
for d in dirs_all:
|
||||
exists = conn.execute(
|
||||
"SELECT 1 FROM classifications WHERE post_id=? AND direction=? LIMIT 1",
|
||||
(post_id, d),
|
||||
).fetchone()
|
||||
if not exists:
|
||||
conn.execute(
|
||||
"""INSERT INTO classifications (post_id, direction, relevance, interest, summary, model)
|
||||
VALUES (?,?,?,?,?,?)""",
|
||||
(post_id, d, res["relevance"], res["interest"], res["summary"],
|
||||
res.get("method") or "classify"),
|
||||
)
|
||||
conn.commit()
|
||||
return res
|
||||
|
||||
|
||||
def work_loop(worker_id: int, stats: dict, stop: threading.Event, limit: int):
|
||||
"""Цикл воркера: claim → классификация → запись, пока очередь не пуста/лимит.
|
||||
|
||||
Соединение создаётся ВНУТРИ потока (SQLite: соединение привязано к thread).
|
||||
"""
|
||||
conn = _new_conn()
|
||||
processed = 0
|
||||
try:
|
||||
while not stop.is_set() and processed < limit:
|
||||
row = claim_post(conn)
|
||||
if row is None:
|
||||
break
|
||||
try:
|
||||
# LLM-вызовы ограничены семифором (не перегружаем Ollama)
|
||||
with LLM_SEM:
|
||||
res = process_post(conn, row["id"], row["text"], row["views"],
|
||||
row["reactions_total"], row["is_own"],
|
||||
row["source_slug"])
|
||||
with stats["lock"]:
|
||||
stats["processed"] += 1
|
||||
if res["classified"]:
|
||||
stats["classified"] += 1
|
||||
if res.get("method") == "llm":
|
||||
stats["llm_ok"] += 1
|
||||
except Exception as e:
|
||||
# вернуть в очередь — пост не теряется; зафиксировать счётчик ошибок
|
||||
try:
|
||||
conn.execute("UPDATE posts SET classified=0 WHERE id=?", (row["id"],))
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
with stats["lock"]:
|
||||
stats["errors"] += 1
|
||||
print(f"[worker {worker_id}] error post {row['id']}: {e}", flush=True)
|
||||
processed += 1
|
||||
finally:
|
||||
conn.close()
|
||||
return processed
|
||||
|
||||
|
||||
def _new_conn() -> sqlite3.Connection:
|
||||
"""Отдельное соединение для воркера (WAL — читатели/писатели параллельны)."""
|
||||
ensure_dirs()
|
||||
conn = sqlite3.connect(DB_PATH, timeout=30)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA busy_timeout=10000")
|
||||
return conn
|
||||
|
||||
|
||||
def run(workers: int = 8, limit: int = 200):
|
||||
"""Параллельная обработка очереди кандидатов. Возвращает (processed, classified, llm_ok, errors)."""
|
||||
stats = {"processed": 0, "classified": 0, "llm_ok": 0, "errors": 0, "lock": threading.Lock()}
|
||||
stop = threading.Event()
|
||||
|
||||
# каждый воркер сам создаёт соединение внутри своего потока
|
||||
print(f"workers={workers}, limit={limit}")
|
||||
with ThreadPoolExecutor(max_workers=workers) as ex:
|
||||
futs = {ex.submit(work_loop, i, stats, stop, limit): i
|
||||
for i in range(workers)}
|
||||
try:
|
||||
for f in as_completed(futs):
|
||||
f.result()
|
||||
except KeyboardInterrupt:
|
||||
stop.set()
|
||||
print("\nостановлено (Ctrl+C)")
|
||||
return stats["processed"], stats["classified"], stats["llm_ok"], stats["errors"]
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="VESTI worker — параллельная обработка очереди кандидатов")
|
||||
ap.add_argument("--workers", type=int, default=8)
|
||||
ap.add_argument("--limit", type=int, default=200)
|
||||
args = ap.parse_args()
|
||||
|
||||
t0 = time.monotonic()
|
||||
proc, cls, llm, err = run(workers=args.workers, limit=args.limit)
|
||||
print(f"Обработано: {proc}, классифицировано: {cls}, через LLM: {llm}, ошибок: {err}, время: {time.monotonic()-t0:.1f}s", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,63 @@
|
||||
# Issue Tracker
|
||||
|
||||
**Tracker:** Gitea (self-hosted, https://gitea.nixg.ru)
|
||||
**Repository:** `estorozhenko/vesti` (mirror from gitverse.ru/kpa39l/vesti)
|
||||
**Web UI:** https://gitea.nixg.ru/estorozhenko/vesti
|
||||
|
||||
This is a custom ("Other") tracker configuration for the Matt Pocock engineering skills (`to-tickets`, `to-spec`, `triage`). Gitea is not natively supported by `/setup-matt-pocock-skills`, so the workflow is recorded here as freeform prose.
|
||||
|
||||
## How to publish issues
|
||||
|
||||
Use the **`tea` CLI** (v0.16.0, at `~/.local/bin/tea`) with the login **`gitea.nixg-full`**:
|
||||
|
||||
```bash
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
|
||||
# Create an issue
|
||||
tea issues create --login gitea.nixg-full --repo estorozhenko/vesti \
|
||||
--title "Ticket title" \
|
||||
--description "Ticket body (use the to-tickets per-ticket template)" \
|
||||
--labels "ready-for-agent"
|
||||
|
||||
# List issues
|
||||
tea issues list --login gitea.nixg-full --repo estorozhenko/vesti -o simple
|
||||
|
||||
# Show one issue (with comments)
|
||||
tea issues --login gitea.nixg-full --repo estorozhenko/vesti 42 --comments
|
||||
|
||||
# Add a comment
|
||||
tea comment --login gitea.nixg-full --repo estorozhenko/vesti 42 "comment"
|
||||
|
||||
# Close / reopen
|
||||
tea issues close --login gitea.nixg-full --repo estorozhenko/vesti 42
|
||||
```
|
||||
|
||||
**Alternative:** the `gitea` MCP server (gitea-mcp, 72 tools) is configured in Hermes `mcp_servers.gitea` — same instance, same token. Prefer MCP tools in agent sessions, `tea` for quick shell checks.
|
||||
|
||||
## Repository and credentials
|
||||
|
||||
- Instance: `https://gitea.nixg.ru` (API v1.26.2, external VPS 5.129.217.146)
|
||||
- Owner/repo: `estorozhenko/vesti` (mirror of `https://gitverse.ru/kpa39l/vesti.git`, issues enabled: yes)
|
||||
- Login (tea): `gitea.nixg-full` — token `hermes-mcp-full`
|
||||
- Token source: `/opt/hermes/.hermes/secrets/git-tokens.env` (`GITEA_NIXG_TOKEN`); also in Hermes `config.yaml` → `mcp_servers.gitea` env `GITEA_TOKEN`.
|
||||
- Git push: to the **origin** (gitverse.ru) via SSH key `/home/estorozhenko/.ssh/gitverse` — the Gitea repo is a read-only mirror and is NOT the push target.
|
||||
|
||||
## Triage labels (created)
|
||||
|
||||
| Label | Color | ID |
|
||||
|---|---|---|
|
||||
| `needs-triage` | `#e11d21` | 6 |
|
||||
| `needs-info` | `#fbca04` | 7 |
|
||||
| `ready-for-agent` | `#0e8a16` | 8 |
|
||||
| `ready-for-human` | `#006b75` | 9 |
|
||||
| `wontfix` | `#ffffff` | 10 |
|
||||
|
||||
Use `ready-for-agent` for tickets published by `to-tickets`.
|
||||
|
||||
## Blocking edges
|
||||
|
||||
Gitea has no native issue-blocking links. Follow the `to-tickets` rule for trackers without native blocking: publish tickets in dependency order (blockers first), and set each ticket's **"Blocked by"** section to the blocking issues' real identifiers (e.g. `#12`, `#13`).
|
||||
|
||||
## Scope
|
||||
|
||||
Tickets for the VESTI project live in `estorozhenko/vesti`. Local fallback: `.scratch/<feature>/issues/` in this project when no Gitea write access.
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-09-20
|
||||
@@ -0,0 +1,124 @@
|
||||
# Design — archive-navigation
|
||||
|
||||
## Контекст
|
||||
|
||||
- Роут: `GET /published` (`web/app.py:735`), шаблон `web/templates/published.html` (42 строки).
|
||||
- Дата: `posts.published_at TEXT` — ISO 8601 с таймзоной (`2026-09-08T08:02:45+00:00`).
|
||||
- Сейчас: `LIMIT 100` без пагинации, без фильтров.
|
||||
- Фронтенд: локальный Bootstrap (`web/static/bootstrap.min.css`), без JS-фреймворков,
|
||||
htmx запрещён (AGENT.MD). Паттерн фильтра по направлениям — query-параметры (`_cand_back`).
|
||||
|
||||
## 1. Хелпер дерева навигации
|
||||
|
||||
В `web/app.py`, рядом с `_db()`:
|
||||
|
||||
```python
|
||||
MONTH_NAMES_RU = ("", "Янв", "Фев", "Мар", "Апр", "Май", "Июн",
|
||||
"Июл", "Авг", "Сен", "Окт", "Ноя", "Дек")
|
||||
|
||||
def _archive_tree(conn):
|
||||
"""Дерево навигации архива: [{year, months:[{m, label, n}], n}] + {no_date:n}.
|
||||
Строится только из существующих периодов; пустые месяцы отсутствуют."""
|
||||
rows = conn.execute(
|
||||
"""SELECT strftime('%Y', published_at) y,
|
||||
strftime('%m', published_at) m, COUNT(*) n
|
||||
FROM posts WHERE status='published' AND published_at IS NOT NULL
|
||||
AND published_at != ''
|
||||
GROUP BY y, m ORDER BY y DESC, m DESC"""
|
||||
).fetchall()
|
||||
years, cur = [], None
|
||||
for r in rows:
|
||||
if cur is None or cur["year"] != r["y"]:
|
||||
cur = {"year": r["y"], "months": [], "n": 0}
|
||||
years.append(cur)
|
||||
cur["months"].append({"m": r["m"], "label": f"{MONTH_NAMES_RU[int(r['m'])]} {r['y']}",
|
||||
"n": r["n"]})
|
||||
cur["n"] += r["n"]
|
||||
no_date = conn.execute(
|
||||
"""SELECT COUNT(*) FROM posts
|
||||
WHERE status='published' AND (published_at IS NULL OR published_at='')"""
|
||||
).fetchone()[0]
|
||||
return {"years": years, "no_date": no_date}
|
||||
```
|
||||
|
||||
- Сортировка: годы и месяцы — по убыванию (свежие сверху, Q9).
|
||||
- Пустой период исключается самой группировкой `GROUP BY` — ровно как Hugo-архивы
|
||||
(`site.RegularPages | groupBy "Date.Year"`), где календарь не итерируется (Q8).
|
||||
|
||||
## 2. Роут `GET /published`
|
||||
|
||||
```python
|
||||
ARCHIVE_PAGE_SIZE = 10
|
||||
|
||||
@app.get("/published", response_class=HTMLResponse)
|
||||
def published(request: Request, year: str = "", month: str = "", page: int = 1):
|
||||
_require_auth(request)
|
||||
page = max(1, page)
|
||||
conn = _db()
|
||||
tree = _archive_tree(conn)
|
||||
|
||||
where, params = ["p.status='published'"], []
|
||||
if year:
|
||||
where.append("strftime('%Y', p.published_at) = ?"); params.append(year)
|
||||
if month:
|
||||
where.append("strftime('%m', p.published_at) = ?"); params.append(month)
|
||||
# год без месяца = все посты за год; "Без даты" не попадает в year/month
|
||||
if not year and not month:
|
||||
where.append("(p.published_at IS NOT NULL AND p.published_at != '')")
|
||||
|
||||
total = conn.execute(f"SELECT COUNT(*) FROM posts p WHERE {' AND '.join(where)}",
|
||||
params).fetchone()[0]
|
||||
pages = max(1, (total + ARCHIVE_PAGE_SIZE - 1) // ARCHIVE_PAGE_SIZE)
|
||||
page = min(page, pages)
|
||||
|
||||
rows = conn.execute(
|
||||
f"""SELECT p.*, s.name source_name,
|
||||
pub.tg_message_id, pub.views pub_views, pub.bundle_path, pub.distributed_dirs
|
||||
FROM posts p
|
||||
LEFT JOIN sources s ON s.id=p.source_id
|
||||
LEFT JOIN published pub ON pub.post_id=p.id
|
||||
WHERE {' AND '.join(where)}
|
||||
ORDER BY p.published_at DESC LIMIT ? OFFSET ?""",
|
||||
params + [ARCHIVE_PAGE_SIZE, (page - 1) * ARCHIVE_PAGE_SIZE]
|
||||
).fetchall()
|
||||
conn.close()
|
||||
|
||||
html = tpl.get_template("published.html").render(
|
||||
posts=rows, tree=tree, year=year, month=month,
|
||||
page=page, pages=pages, total=total,
|
||||
)
|
||||
return HTMLResponse(html)
|
||||
```
|
||||
|
||||
- Блок «Без даты» показывается как ссылка `/?year=&month=`? Нет — отдельный
|
||||
фильтр: кнопка «Без даты» (query `nodate=1`), тоже с деревом и пагинацией.
|
||||
(Добавлено в spec ниже — сценарий «Без даты»).
|
||||
|
||||
## 3. Шаблон `published.html`
|
||||
|
||||
- Обёртка: `row` → `col-lg-9` (список) + `col-lg-3` (дерево, `position-sticky top-2`).
|
||||
- Дерево:
|
||||
- ссылка «📄 Все посты» → `/published` (активна, если нет year/month/nodate).
|
||||
- по годам: `<h6>2026 ▾</h6>` + `<ul>` месяцев; месяц — ссылка
|
||||
`/published?year=2026&month=09`, счётчик `(4)`. Активный месяц подсвечен
|
||||
(`active-row`-стиль, как в кандидатах).
|
||||
- на мобильных: дерево в `collapse` (кнопка «🗂 Архив» + `data-bs-toggle`).
|
||||
- Список: карточки как сейчас, но посты только за выбранный период.
|
||||
- Пагинация снизу: Bootstrap `pagination`, `page-item active` для текущей,
|
||||
prev/next, номера 1..pages (с сохранением year/month/nodate).
|
||||
- Пустой период: `alert-info` «Нет постов за этот период» (дерево всё равно видно).
|
||||
|
||||
## 4. Пагинация
|
||||
|
||||
- Bootstrap HTML, без JS: `?year=2026&month=09&page=N`.
|
||||
- Текущая страница — `active`, prev/next — disabled на границах.
|
||||
- total = число постов в периоде; pages = ceil(total/10).
|
||||
|
||||
## 5. Проверка
|
||||
|
||||
- `openspec validate archive-navigation` — чисто.
|
||||
- `curl -s 'http://127.0.0.1:8400/published?year=2026&month=09'` — видно 4 поста.
|
||||
- `curl -s 'http://127.0.0.1:8400/published?month=07'` — 2 поста.
|
||||
- В HTML дерева нет месяца без постов (только 07, 08, 09) — «февраль отсутствует».
|
||||
- `?page=1`..N — пагинация с номерами; вне диапазона — клампится к последней.
|
||||
- Мобильная вёрстка: сужение окна → колонка сворачивается.
|
||||
@@ -0,0 +1,40 @@
|
||||
# archive-navigation
|
||||
|
||||
Страница «Опубликовано» (`/published`) — настоящий архив с навигацией по годам и месяцам.
|
||||
|
||||
## Why
|
||||
|
||||
Сейчас `/published` — просто плоский список последних 100 опубликованных постов
|
||||
без какой-либо навигации. С ростом архива найти пост по дате невозможно: нет ни
|
||||
периодов, ни фильтров, ни разбивки. Это противоречит назначению страницы —
|
||||
«архив опубликованного».
|
||||
|
||||
Пользователь хочет классический архив: дерево лет/месяцев слева или справа,
|
||||
посты — за выбранный период. Чтобы не занимать место вверху страницы
|
||||
(навигация вверху слишком громоздкая), выбрана правая колонка (Q1–Q10,
|
||||
grill-with-docs 2026-09-20, записано в AGENT.MD).
|
||||
|
||||
## What Changes
|
||||
|
||||
- **web/app.py**, роут `GET /published`:
|
||||
- принимает query-параметры `year` (YYYY), `month` (MM), `page` (по умолчанию 1).
|
||||
- строит дерево навигации: `SELECT strftime('%Y', published_at) y, strftime('%m', published_at) m, COUNT(*) n FROM posts WHERE status='published' GROUP BY y, m` → группировка по годам, внутри — месяцы с количеством. Группа «Без даты» — посты с пустым `published_at` (характер: могут появиться; сейчас 0).
|
||||
- фильтрует посты по выбранному периоду: `year` + `month` (все посты за год, если месяц не указан), либо «Все посты» (без параметров).
|
||||
- пагинация по 10 постов (LIMIT 10 OFFSET).
|
||||
- рендер в `published.html` с деревом навигации, списком постов, подсветкой активного периода.
|
||||
- **web/templates/published.html**:
|
||||
- правая колонка (Bootstrap `col-md-3` + sticky) — дерево годов/месяцев со счётчиками, ссылка «Все посты», группа «Без даты»; пустые периоды не выводятся.
|
||||
- слева — список постов за выбранный период + Bootstrap pagination по 10 (prev/next + номера).
|
||||
- на мобильных — колонка сворачивается (Bootstrap collapse).
|
||||
- **web/app.py**: хелпер построения дерева (чистая функция, тестируемая).
|
||||
- Обновить `STATUS.md`, `CONTEXT.md` (глоссарий уже добавлен).
|
||||
|
||||
## Rollback
|
||||
|
||||
- Откат: `git checkout -- web/app.py web/templates/published.html` (change фиксирует
|
||||
состояние до правок), рестарт vesti-web.
|
||||
|
||||
## Затронутые сервисы/порты
|
||||
|
||||
- vesti-web (:8400), внешний https://vesti.nixg.ru. БД SQLite — только чтение
|
||||
через существующий `_db()` (WAL + busy_timeout).
|
||||
@@ -0,0 +1,97 @@
|
||||
# Spec — archive-navigation
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Архив с навигацией по годам и месяцам
|
||||
|
||||
Страница «Опубликовано» (`/published`) показывает опубликованные посты,
|
||||
сгруппированные по времени публикации, с навигацией по годам и месяцам
|
||||
в правой колонке. Посты показываются за выбранный период (месяц, год или все).
|
||||
|
||||
#### Scenario: Открытие архива без параметров
|
||||
|
||||
Given модератор открывает `/published` без параметров
|
||||
Then показываются опубликованные посты (страница 1, по 10)
|
||||
And в правой колонке — дерево навигации: годы, внутри — месяцы со счётчиками
|
||||
And дерево содержит только периоды, в которых есть посты
|
||||
And сверху дерева есть ссылка «Все посты»
|
||||
|
||||
#### Scenario: Выбор месяца
|
||||
|
||||
Given в дереве навигации есть месяц «Сен 2026» (4 поста)
|
||||
When модератор кликает «Сен 2026»
|
||||
Then показываются только посты за сентябрь 2026 (4 поста)
|
||||
And месяц подсвечен в дереве как активный
|
||||
|
||||
#### Scenario: Выбор года (без месяца)
|
||||
|
||||
Given модератор на странице архива
|
||||
When модератор кликает на год «2026»
|
||||
Then показываются все посты за 2026 год целиком (без разбивки по месяцам)
|
||||
And год подсвечен в дереве как активный
|
||||
|
||||
#### Scenario: Пустой период не появляется в навигации
|
||||
|
||||
Given в архиве нет постов за февраль 2026
|
||||
When модератор смотрит дерево навигации
|
||||
Then в дереве нет ссылки/узла «Фев 2026»
|
||||
(Периоды в дереве порождаются только постами, как в Hugo-архивах)
|
||||
|
||||
### Requirement: Пагинация по 10 постов
|
||||
|
||||
Список постов на странице архива разбивается постранично, по 10 постов
|
||||
на страницу. Навигация — Bootstrap-пагинация с номерами и prev/next,
|
||||
сохраняющая выбранный период.
|
||||
|
||||
#### Scenario: Переход на следующую страницу
|
||||
|
||||
Given в выбранном периоде 25 постов (3 страницы)
|
||||
When модератор кликает «2» в пагинации
|
||||
Then показываются посты 11–20 за тот же период
|
||||
And адрес содержит `page=2` и сохранённые `year`/`month`
|
||||
|
||||
#### Scenario: Пагинация за пределами диапазона
|
||||
|
||||
Given в периоде 1 страница постов
|
||||
When модератор запрашивает `page=5`
|
||||
Then показывается последняя (единственная) страница, без ошибки
|
||||
|
||||
### Requirement: Группа «Без даты»
|
||||
|
||||
Посты со статусом «опубликован», у которых не заполнена дата публикации,
|
||||
не теряются: они показываются в отдельной группе «Без даты» в дереве
|
||||
навигации и по клику — список этих постов.
|
||||
|
||||
#### Scenario: Пост без даты виден в архиве
|
||||
|
||||
Given существует опубликованный пост с пустым `published_at`
|
||||
When модератор открывает `/published`
|
||||
Then в дереве навигации есть ссылка «Без даты»
|
||||
And по клику показываются посты без даты (с пагинацией)
|
||||
|
||||
### Requirement: Правая колонка навигации
|
||||
|
||||
Дерево навигации располагается в правой колонке страницы и остаётся
|
||||
видимым при прокрутке (position-sticky). На узких экранах колонка
|
||||
сворачивается под кнопку «🗂 Архив» (Bootstrap collapse).
|
||||
|
||||
#### Scenario: Правая колонка при прокрутке
|
||||
|
||||
Given модератор прокручивает длинный список постов
|
||||
Then дерево навигации остаётся видимым справа (sticky)
|
||||
|
||||
#### Scenario: Мобильный вид
|
||||
|
||||
Given модератор открывает архив на узком экране (< 768px)
|
||||
Then дерево навигации свёрнуто под кнопку «🗂 Архив» и раскрывается по клику
|
||||
|
||||
### Requirement: Пустой период — заглушка
|
||||
|
||||
Если в выбранном периоде нет постов, показывается сообщение
|
||||
«Нет постов за этот период», дерево навигации остаётся доступным.
|
||||
|
||||
#### Scenario: Запрос пустого периода напрямую
|
||||
|
||||
Given модератор открывает `/published?year=2025&month=01` (нет постов)
|
||||
Then показывается заглушка «Нет постов за этот период»
|
||||
And дерево навигации справа доступно для перехода
|
||||
@@ -0,0 +1,12 @@
|
||||
# Задачи
|
||||
|
||||
- [x] web/app.py: хелпер `_archive_tree(conn)` — дерево годов/месяцев GROUP BY + счётчики + «Без даты»
|
||||
- [x] web/app.py: роут `GET /published` — параметры year/month/page, фильтр периода, пагинация по 10 (LIMIT/OFFSET, клампинг)
|
||||
- [x] web/templates/published.html: правая колонка (col-lg-3 sticky) — дерево навигации, «Все посты», «Без даты»
|
||||
- [x] web/templates/published.html: список постов за период + Bootstrap pagination (prev/next + номера)
|
||||
- [x] web/templates/published.html: collapse на мобильных (кнопка «🗂 Архив»)
|
||||
- [x] web/app.py: `_archive_q` + `tpl.globals["_published_q"]` — построение query-строки с сохранением фильтров
|
||||
- [x] Тест: py_compile + рендер шаблона (дерево, пагинация, active-класс) через .venv
|
||||
- [x] openspec validate archive-navigation — чисто (только стилевые SHALL/MUST warned, спека по-русски)
|
||||
- [x] Рестарт vesti-web, проверка: /published (дерево 2026, 8 постов, без дублей), ?year=2026&month=09 (4), month=08 (2), 2025 (заглушка), пагинация скрыта при 1 стр, битые /published? — 0
|
||||
- [ ] Обновить STATUS.md / TODO.md
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-09-13
|
||||
@@ -0,0 +1,65 @@
|
||||
# design.md — candidates-mail-ui
|
||||
|
||||
## Текущая структура (из чтения кода)
|
||||
|
||||
- `web/app.py` (FastAPI) — роуты:
|
||||
- `GET /candidates` — рендер `candidates.html` (список карточек).
|
||||
- `POST /posts/{id}/approve` — апрув (публикация через publisher).
|
||||
- `POST /posts/{id}/reject` (или аналогичный) — отклонение.
|
||||
- GET /media/{filename} — медиа (добавлен в web-media-preview).
|
||||
- Таблица `posts`: id, source_id (FK), text, direction, lang, views, reactions,
|
||||
media_path, status (new/published/rejected?), classified, created_at.
|
||||
- Таблица `classifications`: post_id, direction, relevance, interest, summary, model.
|
||||
|
||||
## Архитектура нового UI
|
||||
|
||||
### 1. Левая панель: список + группировка + bulk
|
||||
|
||||
- Один маршрут `GET /candidates` с query-параметрами:
|
||||
- `group_by` ∈ {source, date, status} (по умолчанию source)
|
||||
- `filter` (статус: new/published/rejected/all)
|
||||
- `q` (поиск по тексту)
|
||||
- Ответ: тот же HTML (серверный рендер, Jinja2), но layout — две колонки
|
||||
(Bootstrap 5 grid, уже подключён).
|
||||
- Список строится из постов (new/rejected), сгруппированных по выбранному
|
||||
признаку. Группы — сворачиваемые (`<details>`/`<summary>` — без JS).
|
||||
- Каждый пост имеет чекбокс `<input type="checkbox" name="ids" value="{{ id }}">`.
|
||||
- Форма bulk: `POST /candidates/bulk` с `ids[]` и `action` (approve/reject/reject-old).
|
||||
|
||||
### 2. Правая панель: детальный просмотр + действия
|
||||
|
||||
- Выбранный пост: `GET /candidates/{id}` (или та же страница с `?selected=<id>`).
|
||||
- Секции:
|
||||
- Кнопки действий: Опубликовать, Отклонить, Переклассифицировать,
|
||||
Переписать новость (LLM).
|
||||
- «Мой комментарий»: `<textarea name="comment">` + `POST /posts/{id}/comment`.
|
||||
- Оригинальный пост: text (markdown), media (`/media/<basename>`), мета
|
||||
(source @channel, канал, время, views/reactions), direction badge.
|
||||
|
||||
### 3. Новые роуты/данные
|
||||
|
||||
- `posts.comment TEXT` (nullable) — комментарий пользователя к посту.
|
||||
- `POST /posts/{id}/comment` — сохранить комментарий.
|
||||
- `POST /candidates/bulk` — bulk-одобрение/отклонение (транзакция).
|
||||
- `POST /posts/{id}/reclassify` — перезапуск классификатора (classifier.classify_text
|
||||
на посте, обновить direction/classifications).
|
||||
- `POST /posts/{id}/rewrite` — LLM-пересказ (qwen3:8b через Ollama): сгенерировать
|
||||
черновик текста от себя (без копипасты), сохранить в `posts.rewritten_text`,
|
||||
вернуть в текстовое поле для редактирования.
|
||||
|
||||
### 4. Минимум JS
|
||||
|
||||
- Без JS: чекбоксы + `form` с method=post (multi-select через чекбоксы в одной форме).
|
||||
- Группировка/сворачивание: `<details>`.
|
||||
- Автоподсветка выбранного: сервер рендерит `class="active"` по `selected` id.
|
||||
|
||||
## Реализация (по шагам)
|
||||
|
||||
1. Миграция БД: `ALTER TABLE posts ADD COLUMN comment TEXT`.
|
||||
2. Роуты: GET /candidates (двухпанельный), POST /candidates/bulk,
|
||||
POST /posts/{id}/comment, POST /posts/{id}/reclassify, POST /posts/{id}/rewrite.
|
||||
3. Шаблоны: candidates.html переписать (двухколоночная), добавить new/reclassify/
|
||||
comment/rewrite формы.
|
||||
4. Bulk-логика + группировка на сервере (Python).
|
||||
5. Тест: ручной сценарий (создать 2-3 поста, сгруппировать, bulk-отклонить,
|
||||
коммент, реклассификация).
|
||||
@@ -0,0 +1,45 @@
|
||||
## Why
|
||||
|
||||
Пользователю неудобно работать с карточками кандидатов в текущем виде. Сейчас:
|
||||
- Страница кандидатов — это лента карточек (каждая с апрув-кнопкой), нет списка слева,
|
||||
нет меню действий справа, нет группировки, нет bulk-операций.
|
||||
- Пользователь описал целевой интерфейс: «схожий с почтовыми клиентами» — общий список
|
||||
сообщений в левой панели, конкретное сообщение справа.
|
||||
- Отклонять/одобрять по группам (например, «все старые посты отклонить») сейчас нельзя:
|
||||
только по одному.
|
||||
|
||||
## What Changes
|
||||
|
||||
Страница кандидатов `candidates` перерабатывается в двухпанельный интерфейс:
|
||||
|
||||
- **Левая панель** — список кандидатов (сообщений):
|
||||
- элемент списка: аватар/иконка источника, заголовок (направление + краткий текст),
|
||||
мета (источник, дата поступления, views/reactions, статус).
|
||||
- активный/выбранный элемент подсвечивается.
|
||||
- **группировка** (сворачиваемые группы): по источнику, по дате поступления,
|
||||
по статусу (новые / одобренные / отклонённые).
|
||||
- выбор нескольких элементов (чекбоксы) + **bulk-действия** над группой:
|
||||
«отклонить все выбранные», «одобрить все выбранные».
|
||||
- **Правая панель** — детальный просмотр выбранного кандидата:
|
||||
- меню действий: **Опубликовать** (approve), **Отклонить** (reject), **Переклассифицировать**,
|
||||
**Переписать новость под себя** (LLM-пересказ, ввод текста).
|
||||
- блок «Мой комментарий» (textarea, сохраняется).
|
||||
- оригинальный пост со всеми медиа (текст, картинки/видео, ссылки, мета источника).
|
||||
- Хранение комментария — новое поле/таблица (comment в posts или отдельная
|
||||
post_comments), связь с пользователем.
|
||||
- Статусы: у поста уже есть status (new/rejected/published?) — использовать его для
|
||||
группировки; добавить, если нет.
|
||||
|
||||
## Why Not
|
||||
|
||||
- Не делаем drag&drop, не делаем виртуальную бесконечную прокрутку — достаточно
|
||||
пейджинга/группировки (минимум JS, стабильно).
|
||||
- Не меняем существующие роуты approve/reject — только дополняем новыми (bulk,
|
||||
переклассификация, коммент).
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Комментарий: отдельная таблица или поле в posts? (предлагаю поле posts.comment TEXT)
|
||||
- Переписывание: LLM (qwen) генерирует черновик, пользователь редактирует и публикует?
|
||||
- «Переклассифицировать» — повторный запуск классификатора на посте?
|
||||
- Группировка: одновременно несколько группировок или одна выбранная?
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
# Spec — candidates-mail-ui
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Двухпанельный интерфейс кандидатов
|
||||
|
||||
Страница кандидатов разделена на две панели: слева список кандидатов (с группировкой),
|
||||
справа — детальный просмотр выбранного кандидата с меню действий.
|
||||
|
||||
#### Scenario: Пользователь открывает список кандидатов
|
||||
|
||||
Given на странице `GET /candidates`
|
||||
When страница загружается
|
||||
Then левая панель показывает список кандидатов, правая — детали первого/последнего выбранного
|
||||
And активный кандидат подсвечивается
|
||||
|
||||
#### Scenario: Группировка списка по источнику
|
||||
|
||||
Given список кандидатов
|
||||
When пользователь выбирает группировку «по источнику»
|
||||
Then кандидаты сгруппированы по источнику (канал/сайт)
|
||||
And каждая группа сворачивается/разворачивается (без JS, через `<details>`)
|
||||
|
||||
#### Scenario: Группировка по дате поступления
|
||||
|
||||
When пользователь выбирает группировку «по дате поступления»
|
||||
Then кандидаты сгруппированы по дате поступления (сегодня, вчера, ранее)
|
||||
And порядок: новые сверху, старые ниже
|
||||
|
||||
### Requirement: Bulk-операции над выбранными кандидатами
|
||||
|
||||
Пользователь может выбрать несколько кандидатов (чекбоксы) и выполнить массовое
|
||||
действие: одобрить все выбранные / отклонить все выбранные.
|
||||
|
||||
#### Scenario: Массовое отклонение выбранных
|
||||
|
||||
Given на левой панели несколько кандидатов с отмеченными чекбоксами
|
||||
When пользователь нажимает «Отклонить выбранные»
|
||||
Then все отмеченные кандидаты получают статус «отклонён»
|
||||
And действие выполняется транзакционно (все или ни одного)
|
||||
|
||||
#### Scenario: Массовое одобрение выбранных
|
||||
|
||||
When пользователь нажимает «Одобрить выбранные»
|
||||
Then каждый отмеченный кандидат публикуется (вызывается approve-логика)
|
||||
|
||||
#### Scenario: Отклонение всех старых постов группы
|
||||
|
||||
Given группа «ранее» (старые посты)
|
||||
When пользователь выбирает «отклонить все в группе»
|
||||
Then все посты группы получают статус «отклонён»
|
||||
|
||||
### Requirement: Действия над конкретным кандидатом
|
||||
|
||||
Правая панель содержит меню действий: Опубликовать, Отклонить, Переклассифицировать,
|
||||
Переписать новость под себя, блок «Мой комментарий», оригинальный пост со всеми медиа.
|
||||
|
||||
#### Scenario: Опубликовать кандидата
|
||||
|
||||
Given выбран кандидат в правой панели
|
||||
When пользователь нажимает «Опубликовать»
|
||||
Then пост публикуется (существующая логика approve, publisher)
|
||||
|
||||
#### Scenario: Отклонить кандидата
|
||||
|
||||
When пользователь нажимает «Отклонить»
|
||||
Then пост получает статус «отклонён» и исчезает из активных кандидатов
|
||||
|
||||
#### Scenario: Переклассифицировать кандидата
|
||||
|
||||
When пользователь нажимает «Переклассифицировать»
|
||||
Then запускается классификатор для данного поста
|
||||
And направление/relevance/interest обновляются, изменения видны на карточке
|
||||
|
||||
#### Scenario: Переписать новость под себя
|
||||
|
||||
When пользователь нажимает «Переписать новость»
|
||||
Then LLM генерирует черновик пересказа от первого лица (без копипасты)
|
||||
And черновик отображается в текстовом поле для редактирования
|
||||
And пользователь может отредактировать и затем опубликовать
|
||||
|
||||
#### Scenario: Комментарий к кандидату
|
||||
|
||||
Given в правой панели блок «Мой комментарий»
|
||||
When пользователь вводит текст и сохраняет
|
||||
Then комментарий сохраняется и отображается при повторном открытии кандидата
|
||||
|
||||
#### Scenario: Оригинальный пост со всеми медиа
|
||||
|
||||
Given выбран кандидат с медиа
|
||||
Then правая панель показывает оригинальный текст поста, все изображения/видео
|
||||
(через роут /media/), мета источника (канал, время, views/reactions)
|
||||
|
||||
### Requirement: Фильтрация и поиск по списку
|
||||
|
||||
Список кандидатов можно фильтровать по статусу (новые/одобренные/отклонённые)
|
||||
и искать по тексту.
|
||||
|
||||
#### Scenario: Фильтр по статусу
|
||||
|
||||
Given на странице кандидатов
|
||||
When пользователь выбирает фильтр «новые»
|
||||
Then в списке остаются только новые кандидаты
|
||||
|
||||
#### Scenario: Поиск по тексту
|
||||
|
||||
When пользователь вводит текст в поиск
|
||||
Then список фильтруется по вхождению в текст поста
|
||||
@@ -0,0 +1,20 @@
|
||||
# tasks.md — candidates-mail-ui
|
||||
|
||||
## Proposal
|
||||
- [x] proposal.md — двухпанельный интерфейс кандидатов (почтовый клиент), группировка, bulk
|
||||
- [x] design.md — архитектура (роуты, БД, шаблоны, минимум JS)
|
||||
|
||||
## Implement
|
||||
- [x] Проверить схему posts (status, поля) — уточнить, какие статусы есть (new/rejected/published)
|
||||
- [x] Миграция: `ALTER TABLE posts ADD COLUMN comment TEXT` + `rewritten_text TEXT` (сделано в БД)
|
||||
- [x] GET /candidates — двухпанельный рендер (левая колонка список+группировка, правая — детально)
|
||||
- [x] Левая панель: группировка по source/date/status (сворачиваемые <details>), чекбоксы
|
||||
- [x] POST /candidates/bulk — approve/reject/reject-old по ids[] (транзакция)
|
||||
- [x] POST /posts/{id}/comment — сохранить комментарий (comment TEXT)
|
||||
- [x] POST /posts/{id}/reclassify — перезапуск классификатора на посте (реальный qwen, протестирован)
|
||||
- [x] POST /posts/{id}/rewrite — LLM-пересказ (qwen), возврат черновика для редактирования (реальный qwen, протестирован)
|
||||
- [x] Правая панель: меню действий, блок комментария, оригинальный пост со всеми медиа
|
||||
- [x] Шаблоны: candidates.html (двухколоночная), формы новых действий
|
||||
- [x] Тест: группировка, bulk-отклонение, коммент, реклассификация, rewrite, поиск (TestClient, все зелёные)
|
||||
- [x] `openspec validate candidates-mail-ui` — чисто
|
||||
- [x] Обновить STATUS.md / WALKTHROUGH.md
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-09-13
|
||||
@@ -0,0 +1,209 @@
|
||||
## Дизайн
|
||||
|
||||
### 1. Новый модуль `services/publisher/app/gotosocial.py`
|
||||
|
||||
Клиент GoToSocial на httpx (уже есть в требованиях publisher), БЕЗ внешних библиотек.
|
||||
|
||||
```python
|
||||
# services/publisher/app/gotosocial.py
|
||||
"""Клиент GoToSocial (Mastodon-совместимый REST API).
|
||||
|
||||
GtS 0.22.x: POST /api/v1/statuses, POST /api/v2/media, GET /api/v1/accounts/verify_credentials,
|
||||
DELETE /api/v1/statuses/{id}. Аутентификация: Bearer-токен пользователя (scope write).
|
||||
"""
|
||||
import httpx
|
||||
|
||||
from .config import settings
|
||||
|
||||
TIMEOUT = 25.0
|
||||
MAX_MEDIA = 6
|
||||
MAX_TEXT = 5000 # лимит GtS
|
||||
|
||||
|
||||
class GoToSocialError(Exception):
|
||||
def __init__(self, message: str, http_code: int = 502, detail: str = ""):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.http_code = http_code
|
||||
self.detail = detail
|
||||
|
||||
|
||||
def _client() -> httpx.Client:
|
||||
if not settings.gt_social_url:
|
||||
raise GoToSocialError("GT_SOCIAL_URL не задан", http_code=500)
|
||||
if not settings.gt_social_token:
|
||||
raise GoToSocialError("GT_SOCIAL_ACCESS_TOKEN не задан", http_code=500)
|
||||
# GtS доступен напрямую (внутренняя сеть WG), прокси НЕ используется
|
||||
return httpx.Client(
|
||||
base_url=settings.gt_social_url.rstrip("/"),
|
||||
headers={"Authorization": f"Bearer {settings.gt_social_token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
|
||||
|
||||
def post_status(text: str, media_ids: list[str] | None = None, visibility: str = "public") -> str:
|
||||
"""Создаёт статус. Возвращает id статуса (str из JSON GtS)."""
|
||||
if not text:
|
||||
raise GoToSocialError("text пуст", http_code=400)
|
||||
payload = {
|
||||
"status": text[:MAX_TEXT],
|
||||
"visibility": visibility,
|
||||
# GtS принимает markdown: content-type заголовок (не обязательный, статус уходит plaintext)
|
||||
}
|
||||
if media_ids:
|
||||
payload["media_ids"] = media_ids[:MAX_MEDIA]
|
||||
with _client() as c:
|
||||
r = c.post("/api/v1/statuses", json=payload)
|
||||
if r.status_code not in (200, 201):
|
||||
raise GoToSocialError(f"statuses: HTTP {r.status_code} {r.text[:200]}",
|
||||
http_code=403 if r.status_code == 403 else 502)
|
||||
return str(r.json()["id"])
|
||||
|
||||
|
||||
def upload_media(path: str) -> str:
|
||||
"""Загружает медиафайл, возвращает media_id."""
|
||||
if not path:
|
||||
raise GoToSocialError("media path пуст", http_code=400)
|
||||
try:
|
||||
with _client() as c, open(path, "rb") as f:
|
||||
r = c.post("/api/v2/media", files={"file": f}, data={"description": ""})
|
||||
except FileNotFoundError:
|
||||
raise GoToSocialError(f"медиафайл не найден: {path}", http_code=400)
|
||||
if r.status_code not in (200, 201):
|
||||
raise GoToSocialError(f"media: HTTP {r.status_code} {r.text[:200]}",
|
||||
http_code=403 if r.status_code == 403 else 502)
|
||||
return str(r.json()["id"])
|
||||
|
||||
|
||||
def verify_credentials() -> dict | None:
|
||||
"""Проверка токена: GET /api/v1/accounts/verify_credentials. None при ошибке."""
|
||||
try:
|
||||
with _client() as c:
|
||||
r = c.get("/api/v1/accounts/verify_credentials")
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def delete_status(status_id: str) -> bool:
|
||||
try:
|
||||
with _client() as c:
|
||||
r = c.delete(f"/api/v1/statuses/{status_id}")
|
||||
return r.status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
```
|
||||
|
||||
- `upload_media` вызывается ДО `post_status`, чтобы собрать `media_ids`.
|
||||
- Ошибки GtS — в `GoToSocialError` (по образцу TelegramError), main.py ловит и кладёт в `results`.
|
||||
|
||||
### 2. Конфиг (config.py + .env + .env.example)
|
||||
|
||||
```python
|
||||
# config.py добавить поля Settings:
|
||||
gt_social_url: str = ""
|
||||
gt_social_token: str = ""
|
||||
gt_social_visibility: str = "public"
|
||||
# в __post_init__:
|
||||
self.gt_social_url = self.gt_social_url or os.getenv("GT_SOCIAL_URL", "")
|
||||
self.gt_social_token = self.gt_social_token or os.getenv("GT_SOCIAL_ACCESS_TOKEN", "")
|
||||
self.gt_social_visibility = self.gt_social_visibility or os.getenv("GT_SOCIAL_VISIBILITY", "public")
|
||||
```
|
||||
|
||||
.env (корень /opt/vesti, chmod 600) + .env.example:
|
||||
```
|
||||
GT_SOCIAL_URL=https://social.dedinit.ru
|
||||
GT_SOCIAL_ACCESS_TOKEN=<токен пользователя @vesti, scope write>
|
||||
GT_SOCIAL_VISIBILITY=public
|
||||
```
|
||||
|
||||
### 3. Определение fediverse-канала (channels.py)
|
||||
|
||||
Канал считается fediverse, если `@x@y` (два @, есть домен) **или** начинается с `gt:`.
|
||||
Для Telegram это `@channel` (один @, без точки в суффиксе).
|
||||
|
||||
```python
|
||||
def is_gotosocial(ch: str) -> bool:
|
||||
return ch.startswith("gt:") or (ch.count("@") >= 2 and "." in ch.split("@")[2] if False else ch.count("@") >= 2)
|
||||
```
|
||||
|
||||
Проще и надёжнее: **явный префикс** `gt:` — не пересекается с Telegram:
|
||||
- канал `gt:@vesti@dedinit.ru` или просто `gt:default` (берём аккаунт из verify_credentials)
|
||||
- либо конфиг отдельным списком `VESTI_GTS_CHANNELS` (MAY, для чистоты).
|
||||
|
||||
Решение для MVP (просто и без путаницы): **новый конфиг-список `VESTI_GTS_CHANNELS`**
|
||||
(по умолчанию пустой; `@vesti@dedinit.ru`). publisher при публикации дополнительно
|
||||
шлёт во все каналы из этого списка. Это НЕ трогает Telegram-логику и формат `channels`.
|
||||
|
||||
### 4. main.py — публикация в fediverse-каналы
|
||||
|
||||
```python
|
||||
from . import gotosocial
|
||||
|
||||
def _publish_gotosocial(card: Card, ch: str) -> ChannelResult:
|
||||
res = ChannelResult()
|
||||
try:
|
||||
media_ids = []
|
||||
if card.media and Path(card.media).exists():
|
||||
media_ids.append(gotosocial.upload_media(card.media))
|
||||
sid = gotosocial.post_status(text=card.text, media_ids=media_ids or None,
|
||||
visibility=settings.gt_social_visibility)
|
||||
res.message_id = int(sid) if str(sid).isdigit() else 0
|
||||
# GtS не отдаёт views столь просто; views оставляем 0
|
||||
except gotosocial.GoToSocialError as e:
|
||||
res.error = e.message
|
||||
return res
|
||||
```
|
||||
|
||||
В `publish()`:
|
||||
- после обработки Telegram-каналов — если `settings.gt_social_channels` не пуст,
|
||||
для каждого `ch in settings.gt_social_channels` вызывать `_publish_gotosocial`.
|
||||
- в `results` ключ — сам канал (`@vesti@dedinit.ru`), как для TG.
|
||||
- `dry_run=True` — добавить те же каналы в эмуляцию (message_id=0), НЕ ходить наружу.
|
||||
- `all_ok` учитывает и fediverse-каналы (если не dry_run).
|
||||
|
||||
### 5. healthz
|
||||
|
||||
```python
|
||||
gt = gotosocial.verify_credentials()
|
||||
"gotosocial": {
|
||||
"url": settings.gt_social_url,
|
||||
"account": gt.get("username") if gt else None,
|
||||
"token_set": bool(settings.gt_social_token),
|
||||
"account_ok": bool(gt),
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Docker / requirements / .env
|
||||
|
||||
- requirements.txt: изменений НЕ требуется (httpx уже есть).
|
||||
- Dockerfile: без изменений (код копируется в образ COPY app ./app).
|
||||
- docker-compose.yml publisher: env_file ../../.env уже подхватит новые переменные;
|
||||
прокси для GtS не нужен (прямой доступ, проверен 200 из контейнера).
|
||||
|
||||
### 7. Проверка (команды)
|
||||
|
||||
```bash
|
||||
# пересборка и рестарт publisher
|
||||
docker compose -f services/publisher/docker-compose.yml up -d --build
|
||||
# healthz: gotosocial блок
|
||||
curl -s http://127.0.0.1:8410/healthz | python3 -m json.tool
|
||||
# dry_run (не уходит наружу)
|
||||
curl -s -X POST http://127.0.0.1:8410/api/v1/publish -H 'Content-Type: application/json' \
|
||||
-d '{"card":{"text":"тест gotosocial dry_run","direction":"llm"},"dry_run":true}'
|
||||
# реальная публикация (согласовав с пользователем; потом удалить через gotosocial.delete_status)
|
||||
curl -s -X POST http://127.0.0.1:8410/api/v1/publish -H 'Content-Type: application/json' \
|
||||
-d '{"card":{"text":"тест gotosocial publish","direction":"llm"}}'
|
||||
# проверить в ленте: https://social.dedinit.ru/@vesti (или API /api/v1/accounts/{id}/statuses)
|
||||
```
|
||||
|
||||
### 8. Токен
|
||||
|
||||
- Создать OAuth-приложение «vesti-publisher» (POST /api/v1/apps, scopes: write write:media,
|
||||
redirect_uris: urn:ietf:wg:oauth:2.0:oob) — создано 2026-09-13 (client_id=01B09QNDM9YQ2VQVSRXHCVAV6K);
|
||||
- Токен для @vesti получен и записан в .env (GT_SOCIAL_ACCESS_TOKEN): вставлен в БД tokens
|
||||
с user_id=id пользователя vesti из tables users (не accounts!), scope 'read write', access 48 симв.
|
||||
Проверено API verify_credentials (username=vesti).
|
||||
- healthz publisher: token_set=true, account=vesti, account_ok=true.
|
||||
@@ -0,0 +1,89 @@
|
||||
## Why
|
||||
|
||||
У VESTI уже работает публикация в Telegram через изолированный publisher-service
|
||||
(`POST /api/v1/publish`, Docker-контейнер :8410, каналы `@dedinit_vesti`). Но у проекта есть
|
||||
вторая цель — «ленты ботов (Telegram, VK, **fediverse**)» (openspec/config.yaml, домен:
|
||||
медиа-конвейер → банк статей → дайджесты + ленты ботов). Fediverse-нода — собственный
|
||||
GoToSocial на social.dedinit.ru (bigbox, Docker-контейнер, :8082), аккаунт **@vesti**
|
||||
(`@vesti@dedinit.ru`) — обычный пользователь (admin=0), создан и подтверждён.
|
||||
|
||||
Сейчас при approve статьи веб зовёт publisher-service, и тот умеет публиковать **только в
|
||||
Telegram**. Чтобы публиковать одобренные карточки ещё и в свой GoToSocial-аккаунт
|
||||
(от имени vesti), publisher-service нужно расширить fediverse-каналом.
|
||||
|
||||
GtS 0.22.x реализует **Mastodon-совместимый REST API** (проверено: POST /api/v1/apps
|
||||
работает, возвращает client_id/client_secret; у ноды есть действующие OAuth-приложения
|
||||
Phanpy/Tusky/GoToSocial Settings). Значит, публикация в GtS идентична Mastodon:
|
||||
`POST /api/v1/statuses` с Bearer-токеном пользователя и опциональными медиа
|
||||
(`POST /api/v2/media`). Это стандарт, библиотек не требует — хватит httpx (уже есть в
|
||||
publisher).
|
||||
|
||||
## What Changes
|
||||
|
||||
Расширяем **publisher-service** (FastAPI, :8410) мульти-канальной отправкой:
|
||||
помимо Telegram-каналов (`@dedinit_vesti`) появляется fediverse-канал
|
||||
`@vesti@dedinit.ru` — публикация в GoToSocial через Mastodon-совместимый API.
|
||||
|
||||
- Новый модуль `services/publisher/app/gotosocial.py` — клиент GtS:
|
||||
- `post_status(text, media_paths, visibility="public")` → `POST /api/v1/statuses`
|
||||
(текст ≤5000, лимит GtS; разметка: markdown или plaintext);
|
||||
- `upload_media(path)` → `POST /api/v2/media` (multipart) до публикации,
|
||||
вернёт `media_id`; до 6 вложений (лимит GtS);
|
||||
- `get_me()` → GET /api/v1/accounts/verify_credentials (healthz);
|
||||
- `delete_status(id)` → DELETE /api/v1/statuses/{id} (для отмены тестовых);
|
||||
- все GET запросы к GtS напрямую (без прокси — social.dedinit.ru доступен из
|
||||
контейнера, проверено HTTP 200).
|
||||
- Конфиг (config.py + .env): `GT_SOCIAL_URL=https://social.dedinit.ru`,
|
||||
`GT_SOCIAL_ACCESS_TOKEN=<токен @vesti>` (Bearer, scope write),
|
||||
`GT_SOCIAL_VISIBILITY=public` (MAY: private/unlisted).
|
||||
- main.py: `channels`-список может содержать помимо `@...` (Telegram) ещё и
|
||||
fediverse-канал (например `@vesti@dedinit.ru` или префикс `gt:`); publisher умеет
|
||||
отправлять карточку в оба типа каналов, результаты — в `results` как обычно.
|
||||
dry_run также эмулирует fediverse-публикацию (не уходит наружу).
|
||||
- healthz: добавить `gotosocial` блок (url, аккаунт, токен задан?).
|
||||
- Документация: .env.example, README publisher, STATUS.md.
|
||||
|
||||
### Не меняется
|
||||
- Telegram-путь (telegram.py, каналы @dedinit_vesti) — работает как раньше.
|
||||
- Веб :8400 (approve) — публикация по-прежнему через HTTP POST /api/v1/publish;
|
||||
веб-клиент не меняется (только конфиг каналов publisher).
|
||||
- Формат карточки (текст+медиа) — один и тот же для всех каналов.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `gotosocial-publisher`: публикация карточки VESTI в собственный GoToSocial
|
||||
(Mastodon API, Bearer-токен @vesti), медиа-аплоад, healthz, dry_run, delete.
|
||||
|
||||
### Modified Capabilities
|
||||
- `tg-publisher-service`: publisher-service теперь мульти-канальный (Telegram +
|
||||
fediverse); конфиг каналов расширяется.
|
||||
- `vesti-web`: без изменений кода (approve шлёт карточку publisher как раньше,
|
||||
но та уходит и в GtS если канал в конфиге).
|
||||
|
||||
## Impact
|
||||
|
||||
- Затронутые сервисы/порты: publisher-service :8410 (Docker-контейнер vesti-publisher) —
|
||||
только пересборка образа; GtS :8082/8443 (social.dedinit.ru) — читается по HTTPS.
|
||||
- Файлы:
|
||||
- новый: services/publisher/app/gotosocial.py
|
||||
- изменён: services/publisher/app/main.py, config.py, channels.py,
|
||||
services/publisher/Dockerfile (НЕ нужно — httpx уже есть), .env.example
|
||||
- Данные: без миграций БД.
|
||||
- Секреты: GT_SOCIAL_ACCESS_TOKEN (новый, в .env), URL GtS не секретен.
|
||||
- Прокси: Telegram — как раньше через SOCKS5; GoToSocial — НАПРЯМУЮ (внутренняя сеть
|
||||
WG → vps02 → bigbox; из контейнера publisher social.dedinit.ru отвечает 200).
|
||||
- Rollback: убрать канал `@vesti@dedinit.ru` из VESTI_BOT_CHANNELS (или вернуть
|
||||
конфиг) — Telegram-публикация не затронута; модуль gotosocial.py можно оставить
|
||||
невостребованным.
|
||||
|
||||
## Risks
|
||||
|
||||
- Токен @vesti (scope write) — если протухнет, publisher вернёт 401/403 в results с
|
||||
понятной ошибкой; healthz покажет `token_set=false`.
|
||||
- GtS недоступен → HTTP 502 в результатах канала; остальные каналы продолжают работать
|
||||
(fan-out не падает целиком — уже реализовано в main.py).
|
||||
- Лимиты GtS: 5000 символов, 6 вложений, MIME ограничен — текст обрезается до 5000,
|
||||
медиа до 6; неподдерживаемый тип → ошибка в результатах канала.
|
||||
- Спам-защита GtS (rate limit) при частой публикации — допустимо (публикует человек,
|
||||
не автопостинг).
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Fediverse-канал в publisher-service
|
||||
|
||||
* publisher-service MUST уметь публиковать карточку в GoToSocial через Mastodon-совместимый REST API при указании fediverse-канала в запросе или конфиге.
|
||||
* publisher-service MUST отправлять текст статуса через `POST /api/v1/statuses` с заголовком `Authorization: Bearer <GT_SOCIAL_ACCESS_TOKEN>`.
|
||||
* publisher-service MUST обрезать текст до 5000 символов (лимит GtS) перед публикацией, сохраняя ссылку на оригинал последней строкой.
|
||||
* publisher-service MAY поддерживать visibility из конфига (`GT_SOCIAL_VISIBILITY`, default `public`).
|
||||
* publisher-service MUST включать fediverse-канал в fan-out: ошибка одного канала не должна отменять публикацию в остальные (поведение как у Telegram-каналов).
|
||||
* publisher-service MUST в режиме `dry_run` эмулировать публикацию в fediverse без реального HTTP-запроса наружу.
|
||||
|
||||
#### Scenario: Публикация карточки в GoToSocial
|
||||
|
||||
GIVEN конфиг publisher содержит `GT_SOCIAL_URL=https://social.dedinit.ru` и `GT_SOCIAL_ACCESS_TOKEN=<токен @vesti>`,
|
||||
WHEN веб (или curl) отправляет `POST /api/v1/publish` с `card: {text, media?}` и каналом `@vesti@dedinit.ru`,
|
||||
THEN publisher вызывает `POST https://social.dedinit.ru/api/v1/statuses` с Bearer-токеном и текстом,
|
||||
AND ответ содержит `results["@vesti@dedinit.ru"].message_id` = id созданного статуса
|
||||
AND `ok=true`, если GtS вернул 200.
|
||||
|
||||
#### Scenario: Публикация с медиа
|
||||
|
||||
GIVEN у карточки есть `media` и файл существует,
|
||||
WHEN publisher публикует в fediverse-канал,
|
||||
THEN publisher сначала загружает файл через `POST /api/v2/media` (multipart), получает `media_id`,
|
||||
AND передаёт массив `media_ids` в `POST /api/v1/statuses` (до 6 вложений).
|
||||
|
||||
#### Scenario: dry_run не уходит наружу
|
||||
|
||||
GIVEN `dry_run=true` в запросе,
|
||||
WHEN publisher обрабатывает fediverse-канал,
|
||||
THEN в `results["@vesti@dedinit.ru"]` возвращается пустой `ChannelResult()` (message_id=0)
|
||||
AND реальный HTTP-запрос к social.dedinit.ru НЕ выполняется.
|
||||
|
||||
#### Scenario: GtS недоступен
|
||||
|
||||
GIVEN GoToSocial не отвечает (сеть/HTTP 5xx),
|
||||
WHEN publisher публикует в fediverse-канал,
|
||||
THEN в `results["@vesti@dedinit.ru"].error` — понятное сообщение об ошибке
|
||||
AND остальные каналы (Telegram) публикуются как обычно
|
||||
AND `ok=false` (но без общего 502, если хотя бы один канал успешен).
|
||||
|
||||
### Requirement: Конфигурация fediverse
|
||||
|
||||
* publisher-service MUST читать настройки GtS из env: `GT_SOCIAL_URL`, `GT_SOCIAL_ACCESS_TOKEN`, `GT_SOCIAL_VISIBILITY` (SECRETS в .env).
|
||||
* publisher-service MUST добавлять в `/healthz` блок `gotosocial`: url, аккаунт (из verify_credentials), `token_set`.
|
||||
* publisher-service MUST определять fediverse-канал по признаку: содержит `@` + точка (например `@vesti@dedinit.ru`) или префикс `gt:` — и НЕ трактовать его как Telegram (chat_id).
|
||||
|
||||
#### Scenario: healthz показывает состояние GtS
|
||||
|
||||
GIVEN publisher запущен с настроенным GT_SOCIAL_ACCESS_TOKEN,
|
||||
WHEN GET /healthz,
|
||||
THEN ответ содержит `gotosocial: {url, token_set: true, account: <username>}` (или `account_ok: false` при сбое verify_credentials).
|
||||
|
||||
#### Scenario: token не задан
|
||||
|
||||
GIVEN `GT_SOCIAL_ACCESS_TOKEN` пуст,
|
||||
WHEN publisher получает запрос публикации в fediverse-канал,
|
||||
THEN результат канала содержит `error` «GT_SOCIAL_ACCESS_TOKEN не задан» (HTTP-статус общий 502 при пустых остальных),
|
||||
AND healthz показывает `token_set: false`.
|
||||
|
||||
### Requirement: Удаление статуса (поддержка отмены тестовых)
|
||||
|
||||
* delete_status MUST удалять статус по id через `DELETE /api/v1/statuses/{id}` (для отмены тестовых публикаций/очистки канала); это SHOULD-часть publisher-service.
|
||||
|
||||
#### Scenario: Удаление статуса
|
||||
|
||||
GIVEN существует статус с id=123 в GtS,
|
||||
WHEN вызывается `gotosocial.delete_status(123)`,
|
||||
THEN GtS возвращает 200 и статус исчезает из ленты (проверяется GET /api/v1/statuses/123 → 404).
|
||||
@@ -0,0 +1,45 @@
|
||||
## 1. Модуль GoToSocial (services/publisher/app/gotosocial.py)
|
||||
|
||||
- [x] 1.1 Создать `gotosocial.py`: `_client()`, `post_status()`, `upload_media()`, `verify_credentials()`, `delete_status()`, `GoToSocialError`
|
||||
- [x] 1.2 Проверка: `python -c "from app.gotosocial import post_status, verify_credentials"` (в venv) — импорт без ошибок
|
||||
|
||||
## 2. Конфиг
|
||||
|
||||
- [x] 2.1 `config.py`: поля `gt_social_url`, `gt_social_token`, `gt_social_visibility`, `gt_social_channels` + чтение из env
|
||||
- [x] 2.2 `.env` (корень): добавить `GT_SOCIAL_URL=https://social.dedinit.ru`, `GT_SOCIAL_ACCESS_TOKEN=<токен vesti>`, `GT_SOCIAL_VISIBILITY=public`, `VESTI_GTS_CHANNELS=@vesti@dedinit.ru`
|
||||
- [x] 2.3 `.env.example` актуализирован (те же имена, значения-заглушки)
|
||||
|
||||
## 3. Логика каналов (channels.py)
|
||||
|
||||
- [x] 3.1 Добавить `is_gotosocial(ch)` и чтение `VESTI_GTS_CHANNELS` (feediverse-каналы отдельно от Telegram)
|
||||
- [x] 3.2 Проверка: функция отличает `@dedinit_vesti` (TG) от `@vesti@dedinit.ru` (GtS); `gt:`-префикс уходит ТОЛЬКО в fediverse
|
||||
|
||||
## 4. main.py
|
||||
|
||||
- [x] 4.1 `_publish_gotosocial(card, ch)`: upload_media → post_status → ChannelResult (status_id)
|
||||
- [x] 4.2 В `publish()`: после Telegram-каналов обработать fediverse-каналы (settings.gt_social_channels) в том же цикле; `all_ok` учитывает их
|
||||
- [x] 4.3 dry_run: эмулирует fediverse-каналы (message_id=0), без реальных HTTP-запросов
|
||||
- [x] 4.4 healthz: блок `gotosocial` (url, account, token_set, account_ok)
|
||||
- [x] 4.5 Проверка: `curl -s http://127.0.0.1:8410/healthz` → json c gotosocial
|
||||
|
||||
## 5. Сборка и функциональный тест
|
||||
|
||||
- [x] 5.1 `docker compose -f services/publisher/docker-compose.yml up -d --build` — контейнер Up
|
||||
- [x] 5.2 dry_run с fediverse-каналом: results содержит `@vesti@dedinit.ru`, message_id=0, HTTP 200
|
||||
- [x] 5.3 healthz содержит gotosocial c `token_set=true`, account=vesti, account_ok=true
|
||||
- [x] 5.4 Регресс: публикация чисто в Telegram (dry_run) работает как раньше
|
||||
|
||||
## 6. Токен + реальный тест
|
||||
|
||||
- [x] 6.1 Создать аккаунт **vesti** на GtS (admin account create, confirmed, approved) — 2026-09-13
|
||||
- [x] 6.2 Создать OAuth-приложение `vesti-publisher` (POST /api/v1/apps, scope `write write:media`) — client_id=01B09QNDM9YQ2VQVSRXHCVAV6K
|
||||
- [x] 6.3 Токен vesti: вставлен в БД `tokens` (user_id из `users`, scope `read write`, access 48 симв.), записан в .env → healthz `account_ok=true`
|
||||
- [x] 6.4 Реальный тест: публикация текста, публикация с медиа (1 attachment), delete — всё HTTP 200, статусы проверены и удалены
|
||||
|
||||
## 7. Документация и git
|
||||
|
||||
- [x] 7.1 STATUS.md: раздел «Fediverse-публикация (GoToSocial)»
|
||||
- [x] 7.2 WALKTHROUGH.md/PRD.md: фича отмечена
|
||||
- [x] 7.3 git commit + push на gitverse
|
||||
- [x] 7.4 openspec archive (после подтверждения пользователя)
|
||||
- [x] 7.5 backup.sh (обязателен после изменений)
|
||||
+4
-4
@@ -6,7 +6,7 @@
|
||||
- [x] classify.py:87: промпт → DIRECTIONS_CANON
|
||||
- [x] classify.py: LLM-fallback при dict-miss (словарь не дал → LLM, проверка канона) — убирает серые посты
|
||||
- [x] web/app.py:130: directions → DIRECTIONS_CANON
|
||||
- [ ] Переклассификация 466 своих без direction (limit 1000)
|
||||
- [ ] Проверка БД: нет новых направлений вне канона
|
||||
- [ ] `openspec validate unify-directions` — чисто
|
||||
- [ ] Бэкап после правки
|
||||
- [x] Переклассификация 466 своих без direction (limit 1000) — 503+206 постов, LLM-fallback добор
|
||||
- [x] Проверка БД: нет новых направлений вне канона (dev/media/ai устранены; осталось 53 без direction — шум/личное, не новости)
|
||||
- [x] `openspec validate unify-directions` — чисто
|
||||
- [x] Бэкап после правки — `/tmp/vesti.db.bak-unify` + будет полный бэкап
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-09-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
|
||||
created: 2026-09-16
|
||||
skip_specs: true
|
||||
@@ -0,0 +1,202 @@
|
||||
# address-field-enhancement
|
||||
|
||||
## Дизайн
|
||||
|
||||
### 1. Backend: парсер адреса и получение названия
|
||||
|
||||
**Новая функция в `sources/sources.py`** (или отдельный модуль — решаю по месту;
|
||||
парсинг адреса — чистая функция без зависимостей):
|
||||
|
||||
```python
|
||||
_TG_HANDLE_RE = re.compile(r"^(?:https?://)?(?:www\.)?(?:t\.me|telegram\.me)/(?:s/)?([a-zA-Z][a-zA-Z0-9_]{3,31})$")
|
||||
_RSS_URL_RE = re.compile(r"^https?://", re.I)
|
||||
|
||||
def parse_address(address: str, crawler: str) -> dict:
|
||||
"""Разбирает одно поле Address в словарь полей источника.
|
||||
|
||||
telegram: address = @handle | t.me/handle | https://t.me/s/handle | handle
|
||||
→ {slug: handle, channel: handle, url: https://t.me/handle}
|
||||
rss: address = https://...feed.xml | http://...
|
||||
→ {feed_url: address, url: address} (slug — из домена/ручного ввода)
|
||||
Возвращает dict с заполненными полями, пустые — None. Гарантирует валидность
|
||||
по _validate_source (slug для telegram; feed_url для rss).
|
||||
"""
|
||||
addr = (address or "").strip()
|
||||
if crawler == "telegram":
|
||||
m = _TG_HANDLE_RE.match(addr) or _TG_HANDLE_RE.match("https://t.me/" + addr.lstrip("@/"))
|
||||
if not m:
|
||||
return {}
|
||||
handle = m.group(1)
|
||||
return {"slug": handle, "channel": handle, "url": f"https://t.me/{handle}"}
|
||||
else: # rss
|
||||
if not _RSS_URL_RE.match(addr):
|
||||
return {}
|
||||
# slug берём из имени файла/домена, чтобы был стабильным и уникальным
|
||||
from urllib.parse import urlparse
|
||||
host = urlparse(addr).netloc.replace("www.", "").split(".")[0]
|
||||
return {"feed_url": addr, "url": addr, "slug": host}
|
||||
```
|
||||
|
||||
**Получение названия (route preview)** — в `web/app.py`:
|
||||
|
||||
```python
|
||||
@app.get("/sources/preview")
|
||||
async def sources_preview(request: Request, address: str = "", crawler: str = "telegram"):
|
||||
"""GET /sources/preview?address=t.me/linuxklub&crawler=telegram → JSON.
|
||||
|
||||
telegram: get_entity из Telethon → name=title канала.
|
||||
rss: feedparser на адрес → name=feed.title.
|
||||
Ошибки (канал не найден, сеть) → {"error": "..."} (200, чтобы JS читал).
|
||||
"""
|
||||
_require_auth(request)
|
||||
if not (address or "").strip():
|
||||
return {"error": "адрес пуст"}
|
||||
parsed = parse_address(address, crawler)
|
||||
if not parsed:
|
||||
return {"error": "не могу разобрать адрес для crawler=" + crawler}
|
||||
name = None
|
||||
if crawler == "telegram":
|
||||
from crawler.telegram_crawler import make_client
|
||||
from config import TG_SESSION_DIR
|
||||
import asyncio
|
||||
async def _get_title():
|
||||
client = make_client(TG_SESSION_DIR)
|
||||
await client.start()
|
||||
try:
|
||||
ent = await client.get_entity(parsed["channel"])
|
||||
return ent.title
|
||||
finally:
|
||||
await client.disconnect()
|
||||
try:
|
||||
name = asyncio.run(_get_title())
|
||||
except Exception as e:
|
||||
return {"error": f"TG: {e}", **parsed}
|
||||
else:
|
||||
import feedparser
|
||||
try:
|
||||
d = feedparser.parse(parsed["feed_url"])
|
||||
if d.bozo and not d.feed.get("title"):
|
||||
return {"error": f"фид не читается: {d.bozo_exception}", **parsed}
|
||||
name = d.feed.get("title") or None
|
||||
except Exception as e:
|
||||
return {"error": f"RSS: {e}", **parsed}
|
||||
return {"slug": parsed["slug"], "name": name, **parsed}
|
||||
```
|
||||
|
||||
`_form_source` — замена url/channel/feed_url одиночным address:
|
||||
|
||||
```python
|
||||
def _form_source(form) -> dict:
|
||||
def s(k, default=None):
|
||||
v = form.get(k)
|
||||
return (v or "").strip() if v else default
|
||||
crawler = s("crawler", "telegram")
|
||||
src = {
|
||||
"slug": s("slug"),
|
||||
"name": s("name"),
|
||||
"crawler": crawler,
|
||||
"direction": s("direction"),
|
||||
"lang": s("lang", "ru"),
|
||||
"priority": s("priority", "P1"),
|
||||
"enabled": bool(form.get("enabled")),
|
||||
"own": bool(form.get("own")),
|
||||
}
|
||||
parsed = parse_address(s("address"), crawler)
|
||||
if crawler == "telegram":
|
||||
src["channel"] = parsed.get("channel") or s("channel")
|
||||
src["url"] = parsed.get("url") or s("url")
|
||||
src["feed_url"] = s("feed_url")
|
||||
else:
|
||||
src["feed_url"] = parsed.get("feed_url") or s("feed_url")
|
||||
src["url"] = parsed.get("url") or s("url")
|
||||
src["channel"] = s("channel")
|
||||
if not src["slug"] and parsed.get("slug"):
|
||||
src["slug"] = parsed["slug"]
|
||||
return src
|
||||
```
|
||||
|
||||
`_validate_source`: для rss достаточно проверки feed_url (уже есть), для
|
||||
telegram — channel (уже есть). Доп. проверка: если crawler=rss и address не URL
|
||||
→ _form_source уже вернул пустые, валидатор отдаст 'rss-источнику нужен feed_url'.
|
||||
Ничего менять не нужно.
|
||||
|
||||
**DIRECTION_OPTIONS** — в app.py при рендере /sources:
|
||||
|
||||
```python
|
||||
@app.get("/sources")
|
||||
async def sources(request: Request, did: str = ""):
|
||||
...
|
||||
conn = _db()
|
||||
rows = conn.execute(
|
||||
"SELECT DISTINCT direction FROM sources WHERE direction IS NOT NULL AND direction != '' ORDER BY direction"
|
||||
).fetchall()
|
||||
directions = sorted(set(DIRECTIONS) | {r[0] for r in rows})
|
||||
return templates.TemplateResponse("sources.html", {
|
||||
"sources": srcs, "did": did, "directions": directions,
|
||||
"request": request,
|
||||
})
|
||||
```
|
||||
|
||||
### 2. Frontend: форма
|
||||
|
||||
Поля `url`, `channel`, `feed_url` из формы удаляются, вместо них:
|
||||
|
||||
```html
|
||||
<div class="col-auto">
|
||||
<label class="form-label mb-0 small">Address</label>
|
||||
<input type="text" name="address" class="form-control form-control-sm"
|
||||
placeholder="@handle, t.me/name, https://t.me/name или https://.../rss" id="add_address">
|
||||
</div>
|
||||
```
|
||||
|
||||
Кнопка «Найти» (для обоих типов; для telegram — получение названия через TG):
|
||||
|
||||
```html
|
||||
<div class="col-auto">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="btn_fetch"
|
||||
onclick="fetchSourcePreview()">🔎 Найти</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
JS (замена старого скрипта required):
|
||||
|
||||
```html
|
||||
<script>
|
||||
const addCrawler = document.querySelector('form[action="/sources/add"] select[name="crawler"]');
|
||||
const addAddress = document.getElementById('add_address');
|
||||
const addSlug = document.querySelector('form[action="/sources/add"] input[name="slug"]');
|
||||
const addName = document.querySelector('form[action="/sources/add"] input[name="name"]');
|
||||
|
||||
function toggleAddressPlaceholder() {
|
||||
addAddress.placeholder = addCrawler.value === 'telegram'
|
||||
? '@handle, t.me/name, https://t.me/name'
|
||||
: 'https://.../rss или http://...';
|
||||
}
|
||||
addCrawler.addEventListener('change', toggleAddressPlaceholder);
|
||||
toggleAddressPlaceholder();
|
||||
|
||||
async function fetchSourcePreview() {
|
||||
const addr = addAddress.value.trim();
|
||||
if (!addr) { alert('Введите адрес'); return; }
|
||||
const resp = await fetch(`/sources/preview?address=${encodeURIComponent(addr)}&crawler=${addCrawler.value}`);
|
||||
const data = await resp.json();
|
||||
if (data.error) { alert(data.error); return; }
|
||||
if (data.slug) addSlug.value = data.slug;
|
||||
if (data.name) { addName.value = data.name; }
|
||||
else if (addName.value === '') addName.placeholder = 'Название не найдено — введите вручную';
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### 3. Изменения только в форме — edit-форма не трогается
|
||||
|
||||
Таблица и edit (изменение существующих) остаются как есть: у источника в строке
|
||||
показываются channel/feed_url. Для edit поле address не добавляется — там
|
||||
сохранены отношения url/channel/feed_url.
|
||||
|
||||
## Проверка
|
||||
|
||||
1. `openspec validate address-field-enhancement` — чисто.
|
||||
2. `.venv/bin/python -c "from sources.sources import parse_address; print(parse_address('https://t.me/mknewsru','telegram'))"` → slug=mknewsru, channel=mknewsru.
|
||||
3. `curl 'http://127.0.0.1:8400/sources/preview?address=t.me/linuxklub&crawler=telegram'` (с авторизацией) → {"slug":"linuxklub","name":"Linux Club",...}.
|
||||
4. Рестарт vesti-web; ручная проверка формы (address → Найти → название появилось).
|
||||
@@ -0,0 +1,45 @@
|
||||
# address-field-enhancement
|
||||
|
||||
Улучшение формы добавления источника: одно поле Address вместо url/channel/feed_url,
|
||||
авто-подстановка названия и slug, direction с автодополнением из существующих значений.
|
||||
|
||||
## Why
|
||||
|
||||
Пользователь: при добавлении telegram-источника хочется вставить адрес типа
|
||||
`https://t.me/mknewsru`, а название канала получить из TG API автоматически
|
||||
(«Мой компьютер»), slug — из адреса (`mknewsru`). Сейчас приходится вручную
|
||||
заполнять 4 поля (url, channel, feed_url, название), часть из которых избыточна.
|
||||
|
||||
Также вместо жёсткого списка DIRECTIONS в datalist — подставлять реально
|
||||
существующие в БД направления + возможность ввода нового.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **web/templates/sources.html**: поле `channel`/`feed_url`/`url` заменяются
|
||||
одним полем `address`; JS: при выборе crawler подсказка меняется;
|
||||
кнопка «Найти название» (для telegram) вызывает GET /sources/preview?address=...
|
||||
и заполняет slug/name; direction — datalist из `DIRECTIONS` + свободный ввод.
|
||||
- **web/app.py**: новый route `GET /sources/preview` — принимает address,
|
||||
определяет тип (telegram/rss), парсит slug, получает название (telegram:
|
||||
Telethon get_entity; rss: feedparser), возвращает JSON {slug, name, url, channel/feed_url}.
|
||||
`_form_source`: `address` → парсинг в зависимости от crawler (telegram:
|
||||
channel из address, url=t.me/...; rss: feed_url=address, url=address).
|
||||
`_validate_source`: rss проверяет address как URL (непустой), telegram — channel.
|
||||
- **web/templates/sources.html**: datalist direction строится из DIRECTION_OPTIONS
|
||||
(переменная из app.py: DIRECTIONS + уникальные из БД), input остаётся свободным.
|
||||
|
||||
## Why Not
|
||||
|
||||
- Не изменяем БД/схему: `address` — только UI-концепция, в БД по-прежнему
|
||||
url/channel/feed_url.
|
||||
- Не трогаем краулеры: они читают channel/feed_url из yaml — парсинг на этапе
|
||||
добавления в yaml.
|
||||
|
||||
## Impact
|
||||
|
||||
- Форма проще: одно поле для адреса + crawler.
|
||||
- Название и slug подставляются автоматически (для telegram — реальное имя
|
||||
канала; для rss — title фида).
|
||||
- direction: реальные значения из БД, свободный ввод остаётся.
|
||||
- Обратная совместимость: старые источники с заполненными url/channel/feed_url
|
||||
работают как раньше (адрес в форму можно не вводить при edit).
|
||||
@@ -0,0 +1,18 @@
|
||||
# address-field-enhancement
|
||||
|
||||
## Problem
|
||||
|
||||
Форма добавления источника требует url/channel/feed_url/название вручную.
|
||||
Хочется: одно поле Address, название — из TG API (RSS — из фида), slug — из адреса.
|
||||
Direction — datalist из реальных значений БД + свободный ввод.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] sources/sources.py: `parse_address(address, crawler)` — telegram/rss парсинг адреса
|
||||
- [x] web/app.py: route GET /sources/preview (Telethon get_entity / feedparser title)
|
||||
- [x] web/app.py: _form_source — address вместо url/channel/feed_url; slug из address
|
||||
- [x] web/app.py: /sources — DIRECTIONS + уникальные из БД → directions в контекст
|
||||
- [x] web/templates/sources.html: одно поле address, кнопка «Найти» → slug/name; datalist direction из данных
|
||||
- [x] openspec validate clean
|
||||
- [x] рестарт vesti-web; curl preview (telegram: mknewsru→«Мой Компьютер», linuxklub→«Linux Club»; rss: lwn→«LWN.net»)
|
||||
- [x] STATUS.md актуализирован; commit + push gitverse
|
||||
@@ -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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user