mirror of
https://gitverse.ru/kpa39l/vesti.git
synced 2026-09-29 18:05:03 +00:00
Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -10,6 +10,17 @@ TG_SESSION_DIR=/opt/vesti/telegram
|
|||||||
VESTI_BOT_TOKEN=your_bot_token_here
|
VESTI_BOT_TOKEN=your_bot_token_here
|
||||||
# Канал публикации (шаблон dedinit_vesti_<direction>_<lang>_bot)
|
# Канал публикации (шаблон dedinit_vesti_<direction>_<lang>_bot)
|
||||||
VESTI_BOT_CHANNEL=@dedinit_vesti_linux_ru_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_USER=admin
|
||||||
ADMIN_PASSWORD=change_me
|
ADMIN_PASSWORD=change_me
|
||||||
|
|||||||
@@ -51,9 +51,8 @@ openspec validate <change-name> # валидация change
|
|||||||
## Направления (directions)
|
## Направления (directions)
|
||||||
linux, tech, politics, games, electronics, llm. Свой канал @dedinit — источник `own: true`, посты is_own=1, fan-out по направлениям с атрибуцией «Дед в АйТи».
|
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/`.
|
- Скрипт: `/opt/vesti/backup.sh` (по образцу `/opt/icq/backup.sh`): архивирует проект → `/opt/vesti/backups/vesti_<дата>.tar.gz`, копирует на Яндекс.Диск `/mnt/yandex-disk/backup/vesti-backups/`.
|
||||||
- Хранение: локально 7 дней, на ЯД 30 дней (ротация в скрипте).
|
- Хранение: локально 7 дней, на ЯД 30 дней (ротация в скрипте).
|
||||||
- Запуск: root cron `45 2 * * *` (как у icq). ЯД монтируется автоматически (fstab davfs + @reboot).
|
- Запуск: 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,24 @@
|
|||||||
|
# VESTI — новостной конвейер
|
||||||
|
|
||||||
|
Конвейер новостей: краулер источников → классификатор (LLM) → банк статей → веб-интерфейс отбора → публикация в Telegram (@dedinit_vesti) и Fediverse (GoToSocial @vesti@dedinit.ru).
|
||||||
|
|
||||||
|
## Документация (читать при старте сессии)
|
||||||
|
|
||||||
|
- **STATUS.md** — точка входа: текущее состояние, архитектура, доступы. Читать первым.
|
||||||
|
- PRD.md — продуктовое описание.
|
||||||
|
- WALKTHROUGH.md — разборы этапов, команды, засады.
|
||||||
|
- TODO.md — задачи.
|
||||||
|
|
||||||
|
## Расположение и доступ
|
||||||
|
|
||||||
|
- Проект: /opt/vesti (docker compose в корне, services/publisher/)
|
||||||
|
- Веб-интерфейс: https://vesti.nixg.ru (uvicorn :8400, systemd vesti-web.service)
|
||||||
|
- Publisher: docker-контейнер vesti-publisher, 127.0.0.1:8410
|
||||||
|
- БД: SQLite в проекте; медиа в media/
|
||||||
|
- Бэкап: backup.sh → backups/ + Яндекс.Диск (root cron 2:45)
|
||||||
|
- Cron Hermes: vesti-crawler-all-sources (*/30), vesti-watchdog (*/15)
|
||||||
|
|
||||||
|
## Правила
|
||||||
|
|
||||||
|
- При старте сессии из этой папки сначала прочитай STATUS.md (и PRD.md при необходимости), потом действуй.
|
||||||
|
- Логи почтовых/публикационных сервисов — при наличии смотри перед диагнозом.
|
||||||
@@ -1,15 +1,55 @@
|
|||||||
# VESTI — Статус
|
# VESTI — Статус
|
||||||
|
|
||||||
Обновлено: 2026-09-13 (publisher снова на Docker; systemd1 ожил)
|
Обновлено: 2026-09-18 (активны: extract-post-sources, manual-direction, post-translate)
|
||||||
<!--
|
<!--
|
||||||
История обновлений:
|
История обновлений:
|
||||||
2026-09-13 — publisher: Docker-контейнер (основной способ); user-юнит disabled (костыль убран)
|
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-12 — publisher → systemd user-юнит (костыль, пока сломан systemd1)
|
||||||
2026-09-11 — веб-интерфейс: 4 openspec change (CDN/htmx, markdown, даты, фильтр без CDN)
|
2026-09-11 — веб-интерфейс: 4 openspec change (CDN/htmx, markdown, даты, фильтр без CDN)
|
||||||
-->
|
-->
|
||||||
|
|
||||||
## Текущее состояние
|
## Текущее состояние
|
||||||
- Базовый конвейер (прототип) работает: краулер (8 источников, инкрементальный, метрики), классификатор (Ollama qwen3:8b-nothink + словари), банк статей (bundles + origin:own), веб :8400 (кандидаты/approve/reject/опубликованные/метрики).
|
- **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 ЗАВЕРШЁН** (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).
|
- **РЕАЛЬНАЯ ПУБЛИКАЦИЯ РАБОТАЕТ**: бот-контроллер @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 конфликтовал бы).
|
- **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 +100,17 @@ vesti-web (:8400) ──HTTP POST──▶ publisher-service (:8410) ──Bot A
|
|||||||
- [x] **Проверено на реальном канале**: publish ушёл в @dedinit_vesti (message_id 6), текст с <b>& доставлен как есть, без превью; тестовые сообщения удалены (delete_message ok). Канал чист
|
- [x] **Проверено на реальном канале**: publish ушёл в @dedinit_vesti (message_id 6), текст с <b>& доставлен как есть, без превью; тестовые сообщения удалены (delete_message ok). Канал чист
|
||||||
- [x] **publisher временно работает как локальный uvicorn** (127.0.0.1:8410, PID 807769) — Docker недоступен из-за зависшего systemd1 на хосте (внешняя проблема, не vesti)
|
- [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)
|
## Сделано ранее (own-content-hub)
|
||||||
- [x] Миграция БД (migrate_own.py): is_own, is_own_canonical, sources.own, published.distributed_dirs
|
- [x] Миграция БД (migrate_own.py): is_own, is_own_canonical, sources.own, published.distributed_dirs
|
||||||
- [x] sources.yaml: +dedinit (own: true), синк
|
- [x] sources.yaml: +dedinit (own: true), синк
|
||||||
@@ -74,7 +125,7 @@ vesti-web (:8400) ──HTTP POST──▶ publisher-service (:8410) ──Bot A
|
|||||||
- [ ] Проверить результат классификации (в БД: direction заполнены) и выборочно качество на своих постах
|
- [ ] Проверить результат классификации (в БД: direction заполнены) и выборочно качество на своих постах
|
||||||
- [ ] **rich-repost-card в проде**: после оживления systemd1 на хосте пересобрать Docker-контейнер publisher (docker compose -f services/publisher/docker-compose.yml up -d --build), перезапустить веб (подхватит шаблон + comment), проверить approve поста с медиа (send_photo/send_video)
|
- [ ] **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 после подтверждения), архивировать
|
- [ ] **Закрыть 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 (после подтверждения)
|
- [ ] 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 +138,11 @@ docker logs -f vesti-publisher # лог
|
|||||||
curl -s -X POST http://127.0.0.1:8410/api/v1/publish \
|
curl -s -X POST http://127.0.0.1:8410/api/v1/publish \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-d '{"card":{"text":"<b>Тест</b>","direction":"linux","lang":"ru"}}'
|
-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 — не использовать, порт занят)
|
# (старый user-юнит vesti-publisher.service disabled — не использовать, порт занят)
|
||||||
# веб (пароль из .env: VESTI_WEB_PASSWORD or ADMIN_PASSWORD):
|
# веб (пароль из .env: VESTI_WEB_PASSWORD or ADMIN_PASSWORD):
|
||||||
# systemd-юнит: /etc/systemd/system/vesti-web.service (после daemon-reload)
|
# systemd-юнит: /etc/systemd/system/vesti-web.service (после daemon-reload)
|
||||||
@@ -97,8 +153,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
|
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/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/{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/openspec/changes/rich-repost-card/ — change формата публикации (proposal/design/tasks/specs/tg-publisher, валиден)
|
||||||
- /opt/vesti/services/publisher/ — микросервис (app/, Dockerfile, compose, README)
|
- /opt/vesti/services/publisher/ — микросервис (app/, Dockerfile, compose, README)
|
||||||
@@ -113,6 +214,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.
|
- **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 (один). В будущем — направления→каналы (маппинг).
|
- Fan-out: сейчас все каналы из VESTI_BOT_CHANNELS (один). В будущем — направления→каналы (маппинг).
|
||||||
- ~~Бэкфилл канала (задача 2.3)~~ — ЗАВЕРШЁН (2026-09-10): 1040 постов, 846 в БД is_own=1
|
- ~~Бэкфилл канала (задача 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) — юнит подхватится при перезагрузке
|
- 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-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 | Артефакты change: proposal, design, specs (5), tasks | ✅ закрыта | сессия старта |
|
||||||
| 2026-09-08 | PRD.md — требования, архитектура, стек, DoD | ✅ закрыта | сессия старта |
|
| 2026-09-08 | PRD.md — требования, архитектура, стек, DoD | ✅ закрыта | сессия старта |
|
||||||
| 2026-09-08 | Каркас проекта: каталоги, venv, requirements.txt | ✅ закрыта | сессия старта |
|
| 2026-09-08 | Каркас проекта: каталоги, venv, requirements.txt | ✅ закрыта | сессия старта |
|
||||||
@@ -47,10 +51,35 @@
|
|||||||
| 2026-09-11 | Change `web-list-filter-no-js`: фильтрация GET-формами без JS/CDN | ✅ закрыта | 2026-09-11 (кнопка «Применить», без ожидания CDN) |
|
| 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 | Баг бандлов: двойной слэш `/bundle//opt/vesti/...` в published | ✅ закрыта | 2026-09-11 (create_bundle → относительный путь, 4 строки БД обновлены) |
|
||||||
| 2026-09-11 | AGENT.MD — правила проекта (все изменения через OpenSpec) | ✅ закрыта | 2026-09-11 |
|
| 2026-09-11 | AGENT.MD — правила проекта (все изменения через OpenSpec) | ✅ закрыта | 2026-09-11 |
|
||||||
| 2026-09-09 | git-репозитории (gitverse истина, gitea зеркало) — /opt/vesti НЕ git-репо | 🔵 открыта | |
|
| 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-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 | После перезагрузки не поднялся веб :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 | Бэкап 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 | 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 (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) |
|
||||||
+220
@@ -401,3 +401,223 @@ sudo systemctl restart vesti-web # active; login → 200, candidates/published
|
|||||||
2. Тест публикаци с медиа сквозь веб (approve с media_path → publisher send_photo) — если ещё не проверен.
|
2. Тест публикаци с медиа сквозь веб (approve с media_path → publisher send_photo) — если ещё не проверен.
|
||||||
3. git-репозиторий /opt/vesti (gitverse истина, gitea зеркало).
|
3. git-репозиторий /opt/vesti (gitverse истина, gitea зеркало).
|
||||||
4. openspec archive: fix-approve-dirn, web-media-preview, rich-repost-card + старые changes (после подтверждения).
|
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/* и др.) — НЕ трогал.
|
||||||
|
|||||||
@@ -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,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:87: промпт → DIRECTIONS_CANON
|
||||||
- [x] classify.py: LLM-fallback при dict-miss (словарь не дал → LLM, проверка канона) — убирает серые посты
|
- [x] classify.py: LLM-fallback при dict-miss (словарь не дал → LLM, проверка канона) — убирает серые посты
|
||||||
- [x] web/app.py:130: directions → DIRECTIONS_CANON
|
- [x] web/app.py:130: directions → DIRECTIONS_CANON
|
||||||
- [ ] Переклассификация 466 своих без direction (limit 1000)
|
- [x] Переклассификация 466 своих без direction (limit 1000) — 503+206 постов, LLM-fallback добор
|
||||||
- [ ] Проверка БД: нет новых направлений вне канона
|
- [x] Проверка БД: нет новых направлений вне канона (dev/media/ai устранены; осталось 53 без direction — шум/личное, не новости)
|
||||||
- [ ] `openspec validate unify-directions` — чисто
|
- [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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-09-16
|
||||||
|
skip_specs: true
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# fix-sources-add-channel
|
||||||
|
|
||||||
|
## Дизайн
|
||||||
|
|
||||||
|
### 1. Форма добавления — поле channel (web/templates/sources.html)
|
||||||
|
|
||||||
|
Вставить после поля `url` (перед `crawler`), чтобы telegram-поля шли вместе:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="col-auto">
|
||||||
|
<label class="form-label mb-0 small">channel (telegram)</label>
|
||||||
|
<input type="text" name="channel" class="form-control form-control-sm"
|
||||||
|
placeholder="@handle или t.me/..." id="add_channel">
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Поле обязательное только для telegram: маленький inline-скрипт в конце формы
|
||||||
|
переключает атрибут `required` при смене `select[name=crawler]`:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script>
|
||||||
|
const addCrawler = document.querySelector('form[action="/sources/add"] select[name="crawler"]');
|
||||||
|
const addChannel = document.getElementById('add_channel');
|
||||||
|
function toggleChannelRequired() {
|
||||||
|
addChannel.required = addCrawler.value === 'telegram';
|
||||||
|
}
|
||||||
|
addCrawler.addEventListener('change', toggleChannelRequired);
|
||||||
|
toggleChannelRequired();
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Таблица — показ channel у telegram (web/templates/sources.html)
|
||||||
|
|
||||||
|
В колонке crawler (строки 91-93) добавить вывод channel для telegram,
|
||||||
|
по аналогии с feed_url для rss:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<td><span class="badge text-bg-secondary">{{ s['crawler'] }}</span>
|
||||||
|
{% if s['crawler'] == 'rss' and s['feed_url'] %}<br><small class="text-muted">{{ s['feed_url'] }}</small>{% endif %}
|
||||||
|
{% if s['crawler'] == 'telegram' and s['channel'] %}<br><small class="text-muted">{{ s['channel'] }}</small>{% endif %}
|
||||||
|
</td>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Backend — без изменений
|
||||||
|
|
||||||
|
`web/app.py::_form_source` уже собирает `channel` (строка 877),
|
||||||
|
`sources/sources.py::_validate_source` уже проверяет (строка 218),
|
||||||
|
`db_source_to_yaml` уже пишет в yaml (строка 181). Правки только в шаблоне.
|
||||||
|
|
||||||
|
## Проверка
|
||||||
|
|
||||||
|
1. `openspec validate fix-sources-add-channel` — чисто.
|
||||||
|
2. Ручной тест через веб: добавить telegram-источник с channel → success;
|
||||||
|
без channel → браузер подсветит поле, серверная ошибка не нужна.
|
||||||
|
3. Рестарт `vesti-web`, проверить страницу /sources.
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# fix-sources-add-channel
|
||||||
|
|
||||||
|
Добавление telegram-источника из веб-модерации падало с ошибкой
|
||||||
|
«telegram-источнику нужен channel», хотя пользователь заполнял slug и название.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
В форме добавления источника (`web/templates/sources.html`) поле `channel`
|
||||||
|
отсутствовало. `_form_source()` в `web/app.py` читает `channel` из формы,
|
||||||
|
`_validate_source()` в `sources/sources.py:218` требует его для `crawler=telegram`
|
||||||
|
— поэтому любой telegram-источник, добавленный через веб, отклонялся,
|
||||||
|
а rss-источник — нет (feed_url в форме есть).
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- **web/templates/sources.html (форма добавления)**: добавлено поле `channel`
|
||||||
|
с подсказкой «@handle или t.me/... для telegram»; для `crawler=telegram`
|
||||||
|
становится `required` (маленький inline-скрипт переключает).
|
||||||
|
- **web/templates/sources.html (таблица)**: у telegram-источников под crawler
|
||||||
|
показывается `channel` (аналогично feed_url у rss), чтобы было видно,
|
||||||
|
какой канал привязан.
|
||||||
|
|
||||||
|
## Why Not
|
||||||
|
|
||||||
|
- Не трогаем backend: `_form_source`, `_validate_source`, `add_or_update_source`
|
||||||
|
уже корректно работают с полем `channel` — не хватало только UI.
|
||||||
|
- Не делаем авто-подстановку channel из url — разные форматы
|
||||||
|
(@handle, t.me/name, https://t.me/s/name), подстановка ненадёжна.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Telegram-источники снова можно добавлять из веб-модерации.
|
||||||
|
- В таблице виден привязанный канал — легче отлаживать нерабочие источники.
|
||||||
|
- Никаких изменений БД и БД-миграций — только шаблон.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# fix-sources-add-channel
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Форма добавления источника не имеет поля `channel`, хотя валидатор
|
||||||
|
(`_validate_source`) требует его для telegram-источников → добавление
|
||||||
|
telegram-источника из веб падает с «telegram-источнику нужен channel».
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [x] sources.html (add-форма): поле `channel` после url, required для telegram
|
||||||
|
- [x] sources.html (таблица): показ channel у telegram-источников
|
||||||
|
- [x] openspec validate clean
|
||||||
|
- [x] рестарт vesti-web; ручная проверка /sources (добавление telegram-источника)
|
||||||
|
- [x] STATUS.md актуализирован; commit + push gitverse
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-09-14
|
||||||
|
skip_specs: true
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
## Design
|
||||||
|
|
||||||
|
### 1. `_fetch_candidates` — статус по умолчанию vs явный
|
||||||
|
|
||||||
|
Текущее (строки ~151-166):
|
||||||
|
```python
|
||||||
|
where.append("p.is_own=0")
|
||||||
|
where.append("p.status='new'")
|
||||||
|
...
|
||||||
|
if status and status != "new":
|
||||||
|
where.append("p.status=?")
|
||||||
|
params.append(status)
|
||||||
|
```
|
||||||
|
|
||||||
|
Правка:
|
||||||
|
```python
|
||||||
|
where.append("p.is_own=0")
|
||||||
|
if not status:
|
||||||
|
# по умолчанию — только кандидаты (new); явный статус ниже
|
||||||
|
where.append("p.status='new'")
|
||||||
|
else:
|
||||||
|
# явный статус (new/rejected/published) — единственный фильтр статуса
|
||||||
|
where.append("p.status=?")
|
||||||
|
params.append(status)
|
||||||
|
if direction:
|
||||||
|
where.append("p.direction=?")
|
||||||
|
params.append(direction)
|
||||||
|
if own in ("1", "0"):
|
||||||
|
where.append("p.is_own=?")
|
||||||
|
params.append(int(own))
|
||||||
|
if q:
|
||||||
|
where.append("(p.text LIKE ? OR p.summary LIKE ?)")
|
||||||
|
params += [f"%{q}%", f"%{q}%"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Скрытые поля в формах правой панели
|
||||||
|
|
||||||
|
В `candidates.html` в каждую форму правой панели (approve, reclassify, rewrite,
|
||||||
|
reject, comment) добавить:
|
||||||
|
```html
|
||||||
|
<input type="hidden" name="group_by" value="{{ group_by }}">
|
||||||
|
<input type="hidden" name="direction" value="{{ direction }}">
|
||||||
|
<input type="hidden" name="own" value="{{ own }}">
|
||||||
|
<input type="hidden" name="q" value="{{ q }}">
|
||||||
|
```
|
||||||
|
|
||||||
|
(Переменные `group_by`, `direction`, `own`, `q` уже доступны в контексте рендера
|
||||||
|
роута `/candidates`.)
|
||||||
|
|
||||||
|
### 3. Роуты POST-действий — сохранять контекст
|
||||||
|
|
||||||
|
Общий хелпер для построения URL возврата:
|
||||||
|
```python
|
||||||
|
def _cand_back(group_by="", direction="", own="", q="", **extra):
|
||||||
|
parts = []
|
||||||
|
if group_by:
|
||||||
|
parts.append(f"group_by={group_by}")
|
||||||
|
if direction:
|
||||||
|
parts.append(f"direction={direction}")
|
||||||
|
if own in ("0", "1"):
|
||||||
|
parts.append(f"own={own}")
|
||||||
|
if q:
|
||||||
|
parts.append(f"q={quote(q)}")
|
||||||
|
for k, v in extra.items():
|
||||||
|
if v:
|
||||||
|
parts.append(f"{k}={v}")
|
||||||
|
return "/candidates?" + "&".join(parts) if parts else "/candidates"
|
||||||
|
```
|
||||||
|
|
||||||
|
Применение:
|
||||||
|
- `reject(post_id, request, group_by="", direction="", own="", q="")`:
|
||||||
|
`return RedirectResponse(url=_cand_back(group_by, direction, own, q, status="rejected"), status_code=302)`
|
||||||
|
→ пользователь остаётся в своей группировке, видит список отклонённых.
|
||||||
|
- `comment`/`reclassify`/`rewrite`: `_cand_back(group_by, direction, own, q, selected=post_id)`.
|
||||||
|
- `approve`: после успеха — `_cand_back(group_by, direction, own, q, status="published")`
|
||||||
|
(пользователь видит опубликованные в той же группировке) или `/published`.
|
||||||
|
Решение: approve возвращает в кандидаты с `status=published` в той же группировке,
|
||||||
|
чтобы контекст не терялся. Ошибки — `_cand_back(..., selected=post_id, error=err)`.
|
||||||
|
|
||||||
|
FastAPI: параметры форм объявляются как `group_by: str = Form("")` и т.д.
|
||||||
|
|
||||||
|
## Верификация
|
||||||
|
|
||||||
|
- `openspec validate keep-candidates-context` — чисто.
|
||||||
|
- Юнит: `_fetch_candidates(c,'','rejected','','','date')` → возвращает
|
||||||
|
отклонённые (не пусто); `_fetch_candidates(c,'','','','','date')` → new внешние.
|
||||||
|
- Живой: POST /posts/{id}/reject с form group_by=date → редирект на
|
||||||
|
`/candidates?group_by=date&status=rejected`, список не пуст.
|
||||||
|
- Рестарт vesti-web.
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Два бага при работе со списком кандидатов:
|
||||||
|
|
||||||
|
1. **Пустой список при `?status=rejected`.** После фикса
|
||||||
|
`candidates-only-external-new` в `_fetch_candidates` жёстко добавлено
|
||||||
|
`p.status='new'`. Когда пользователь явно выбирает `status=rejected`
|
||||||
|
(или `published`), в WHERE попадают ОБА условия: `p.status='new' AND
|
||||||
|
p.status='rejected'` → выборка всегда пустая, страница показывает
|
||||||
|
«Нет кандидатов по фильтру».
|
||||||
|
|
||||||
|
2. **Сброс группировки после действия.** Роут `/posts/{id}/reject`
|
||||||
|
редиректит на `/candidates?status=rejected` без сохранения `group_by`
|
||||||
|
(и direction/own/q). Пользователь был в группировке «дата» → после
|
||||||
|
отклонения его выбрасывает на `?status=rejected` с дефолтной группировкой
|
||||||
|
«источник». Аналогично approve/reclassify/rewrite/comment редиректят на
|
||||||
|
`?selected={id}` без сохранения группировки.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
1. **`web/app.py`, `_fetch_candidates`:** жёсткое `p.status='new'` добавлять
|
||||||
|
только когда параметр `status` пуст (значение по умолчанию = кандидаты
|
||||||
|
new). Если `status` задан явно (`new`/`rejected`/`published`) — применять
|
||||||
|
его как единственный фильтр статуса. `p.is_own=0` остаётся всегда
|
||||||
|
(свои посты не кандидаты).
|
||||||
|
|
||||||
|
2. **Формы правой панели в `candidates.html`** (approve, reclassify, rewrite,
|
||||||
|
reject, comment): добавить скрытые поля `group_by`, `direction`, `own`, `q`
|
||||||
|
со значениями текущей страницы.
|
||||||
|
|
||||||
|
3. **Роуты POST-действий** (approve, reclassify, rewrite, reject, comment):
|
||||||
|
читать `group_by`/`direction`/`own`/`q` из формы и строить редирект с этими
|
||||||
|
параметрами, чтобы пользователь остался в той же группировке/фильтре.
|
||||||
|
|
||||||
|
## Why Not
|
||||||
|
|
||||||
|
- Не парсить `Referer`: хрупко и небезопасно.
|
||||||
|
- Не возвращать на `/published` после approve без сохранения контекста:
|
||||||
|
пользователь работает в списке кандидатов и хочет остаться в нём.
|
||||||
|
- Не менять SQL-структуру counts: counts считаются по внешним постам и уже
|
||||||
|
корректны.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Файлы: `web/app.py`, `web/templates/candidates.html`.
|
||||||
|
- Данные: без миграций БД.
|
||||||
|
- Сервис: `vesti-web` (:8400) — перезапуск.
|
||||||
|
- Rollback: откатить правки, перезапустить.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# keep-candidates-context
|
||||||
|
|
||||||
|
- [x] Создан OpenSpec change (proposal/design) — skip_specs: true
|
||||||
|
- [x] _fetch_candidates: жёсткое status='new' только при пустом status; явный статус — единственный фильтр
|
||||||
|
- [x] candidates.html: скрытые поля group_by/direction/own/q в формах approve/reclassify/rewrite/reject/comment
|
||||||
|
- [x] Роуты POST-действий: редирект сохраняет group_by и фильтры
|
||||||
|
- [x] openspec validate keep-candidates-context — чисто
|
||||||
|
- [x] Юнит: status=rejected → 4 поста (не пусто); по умолчанию → 163 new внешних
|
||||||
|
- [x] Живой: hidden-поля в формах; ?status=rejected → 4 поста, нет «Нет кандидатов»
|
||||||
|
- [x] Рестарт vesti-web
|
||||||
|
- [x] Обновить STATUS.md / TODO.md
|
||||||
|
- [x] Git push в gitverse
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user