mirror of
https://gitverse.ru/kpa39l/vesti.git
synced 2026-09-29 18:05:03 +00:00
Compare commits
4 Commits
1ae4b14d24
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c8ccd7e187 | |||
| a10ab9fa3c | |||
| 56610fa836 | |||
| 6a1c894ff7 |
@@ -27,3 +27,7 @@ ADMIN_PASSWORD=change_me
|
|||||||
# Ollama
|
# Ollama
|
||||||
OLLAMA_URL=http://127.0.0.1:11434
|
OLLAMA_URL=http://127.0.0.1:11434
|
||||||
OLLAMA_MODEL=qwen3:8b-nothink
|
OLLAMA_MODEL=qwen3:8b-nothink
|
||||||
|
# RSS-краулер VESTI (честный User-Agent, обязателен для публичных лент)
|
||||||
|
RSS_USER_AGENT=vesti-rss/0.1 (+https://vesti.nixg.ru)
|
||||||
|
# Таймаут HTTP-запроса к ленте, сек
|
||||||
|
RSS_DEFAULT_TIMEOUT=20
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
# AGENT.MD — Правила проекта VESTI
|
# AGENT.MD — Правила проекта VESTI
|
||||||
|
|
||||||
## Правило №1: ВСЕ изменения — через OpenSpec
|
## Правило №1: ВСЕ изменения — через OpenSpec
|
||||||
|
- **Перед каждым планируемым изменением — обязательно grill-with-docs** (интервью по дизайн-дереву: глоссарий в CONTEXT.md + ADR при необходимости). Только после достижения общего понимания с пользователем — создавать change.
|
||||||
- **Любое изменение проекта (багфикс, фича, рефакторинг, конфиг, деплой, docs) оформляется как отдельный change** в `/opt/vesti/openspec/changes/<change-name>/`.
|
- **Любое изменение проекта (багфикс, фича, рефакторинг, конфиг, деплой, docs) оформляется как отдельный change** в `/opt/vesti/openspec/changes/<change-name>/`.
|
||||||
- **НЕ начинать правки кода/файлов, пока change не создан и не провалидирован (`openspec validate` чисто).** Это обязательное требование (у VESTI нет бэкапа — OpenSpec фиксирует состояние и откат).
|
- **НЕ начинать правки кода/файлов, пока change не создан и не провалидирован (`openspec validate` чисто).** Это обязательное требование (у VESTI нет бэкапа — OpenSpec фиксирует состояние и откат).
|
||||||
- **Сразу после правок — обновить STATUS.md/TODO.md и сделать бэкап** (см. «Бэкап» ниже).
|
- **Сразу после правок — обновить STATUS.md/TODO.md и сделать бэкап** (см. «Бэкап» ниже).
|
||||||
@@ -16,6 +17,7 @@
|
|||||||
|
|
||||||
## Стек и архитектура
|
## Стек и архитектура
|
||||||
- Новостной агрегатор: TG-краулер → классификатор (Ollama qwen3:8b-nothink) → веб-модерация → публикация в Telegram.
|
- Новостной агрегатор: TG-краулер → классификатор (Ollama qwen3:8b-nothink) → веб-модерация → публикация в Telegram.
|
||||||
|
- Веб: :8400 — страницы /candidates (модерация), /crawlers (статус краулеров, запуск воркера), /sources (управление источниками: add/edit/toggle-pause/delete; источник истины — sources.yaml).
|
||||||
- `vesti-web` (FastAPI+Jinja2, :8400, systemd-юнит) --HTTP POST--> `publisher-service` (FastAPI, Docker-контейнер, :8410) --Bot API (SOCKS5 127.0.0.1:1080)--> Telegram @dedinit_vesti.
|
- `vesti-web` (FastAPI+Jinja2, :8400, systemd-юнит) --HTTP POST--> `publisher-service` (FastAPI, Docker-контейнер, :8410) --Bot API (SOCKS5 127.0.0.1:1080)--> Telegram @dedinit_vesti.
|
||||||
- БД: SQLite `/opt/vesti/db/vesti.db` — открывать с WAL + busy_timeout (иначе `database is locked` при фоновом классификаторе).
|
- БД: SQLite `/opt/vesti/db/vesti.db` — открывать с WAL + busy_timeout (иначе `database is locked` при фоновом классификаторе).
|
||||||
- Фронтенд: **без внешних CDN** (никаких unpkg/jsdelivr). Bootstrap локален: `web/static/bootstrap.min.css`, htmx запрещён. approve/reject — обычные POST-формы.
|
- Фронтенд: **без внешних CDN** (никаких unpkg/jsdelivr). Bootstrap локален: `web/static/bootstrap.min.css`, htmx запрещён. approve/reject — обычные POST-формы.
|
||||||
@@ -43,6 +45,11 @@ sudo systemctl restart vesti-web # веб :8400 (0.0.0.0, uvicorn web.a
|
|||||||
docker compose -f services/publisher/docker-compose.yml up -d --build # publisher :8410
|
docker compose -f services/publisher/docker-compose.yml up -d --build # publisher :8410
|
||||||
curl -s http://127.0.0.1:8410/healthz # ok, bot=dedinit_controller_bot, proxy, channels
|
curl -s http://127.0.0.1:8410/healthz # ok, bot=dedinit_controller_bot, proxy, channels
|
||||||
.venv/bin/python -m crawler.telegram_crawler --all # краулер (инкрементально)
|
.venv/bin/python -m crawler.telegram_crawler --all # краулер (инкрементально)
|
||||||
|
.venv/bin/python -m crawler.rss_crawler --all # RSS/Atom-краулер (условные GET, etag/modified; rss_state)
|
||||||
|
.venv/bin/python -m crawler.rss_crawler --source lwn --dry-run # сухой прогон без записи в БД
|
||||||
|
.venv/bin/python -m crawler.crawl_sources --all # фаза 1: сбор кандидатов в очередь (telegram+rss)
|
||||||
|
.venv/bin/python -m crawler.worker --workers 8 --limit 200 # фаза 3: параллельная обработка очереди (ThreadPoolExecutor)
|
||||||
|
MAX_CONCURRENT_LLM=2 .venv/bin/python -m crawler.worker --workers 8 # лимит одновременных LLM-запросов к Ollama
|
||||||
CLASSIFY_TIMEOUT=20 .venv/bin/python -m classifier.classify --db db/vesti.db --limit 200
|
CLASSIFY_TIMEOUT=20 .venv/bin/python -m classifier.classify --db db/vesti.db --limit 200
|
||||||
openspec validate <change-name> # валидация change
|
openspec validate <change-name> # валидация change
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,24 +1,35 @@
|
|||||||
# VESTI — новостной конвейер
|
# Ctrl-C: VESTI — новостной конвейер
|
||||||
|
|
||||||
Конвейер новостей: краулер источников → классификатор (LLM) → банк статей → веб-интерфейс отбора → публикация в Telegram (@dedinit_vesti) и Fediverse (GoToSocial @vesti@dedinit.ru).
|
## Что это
|
||||||
|
|
||||||
## Документация (читать при старте сессии)
|
Конвейер новостей: краулер источников → классификатор (LLM) → банк статей →
|
||||||
|
веб-интерфейс отбора (модерация) → публикация в Telegram (@dedinit_vesti)
|
||||||
|
и Fediverse (GoToSocial @vesti@dedinit.ru).
|
||||||
|
|
||||||
- **STATUS.md** — точка входа: текущее состояние, архитектура, доступы. Читать первым.
|
## Стек
|
||||||
- PRD.md — продуктовое описание.
|
|
||||||
- WALKTHROUGH.md — разборы этапов, команды, засады.
|
|
||||||
- TODO.md — задачи.
|
|
||||||
|
|
||||||
## Расположение и доступ
|
FastAPI + Jinja2 (vesti-web, :8400), publisher-service (Docker, :8410),
|
||||||
|
SQLite db/vesti.db, локальная LLM — Ollama qwen3:8b-nothink.
|
||||||
- Проект: /opt/vesti (docker compose в корне, services/publisher/)
|
Фронтенд — локальный Bootstrap 5.3 (без CDN), без htmx/JS-фреймворков.
|
||||||
- Веб-интерфейс: 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 при необходимости), потом действуй.
|
- **ВСЕ изменения — через OpenSpec** (change в openspec/changes/), перед каждым
|
||||||
- Логи почтовых/публикационных сервисов — при наличии смотри перед диагнозом.
|
планируемым изменением — **grill-with-docs** (обязательно, AGENT.MD).
|
||||||
|
- НЕ начинать правки кода, пока change не создан и `openspec validate` не чисто.
|
||||||
|
- После правок — STATUS.md/TODO.md + бэкап (автокрон, вручную не запускать).
|
||||||
|
- Дата в UI — фильтр `| dt` (ЧЧ:ММ ДД.ММ.ГГГГ), markdown — XSS-safe фильтр.
|
||||||
|
- Секреты — только в .env, не коммитить; .env.example актуализировать.
|
||||||
|
- Ссылки на ресурсы и доступы — фиксировать в файлах проекта.
|
||||||
|
|
||||||
|
## Запуск / проверка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/vesti
|
||||||
|
sudo systemctl restart vesti-web # веб :8400
|
||||||
|
docker compose -f services/publisher/docker-compose.yml up -d --build # publisher :8410
|
||||||
|
curl -s http://127.0.0.1:8410/healthz
|
||||||
|
openspec validate <change-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
Внешний доступ: https://vesti.nixg.ru (Caddy на VPS02, reverse_proxy 10.8.0.2:8400).
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
# CONTEXT.md — Глоссарий домена VESTI
|
||||||
|
|
||||||
|
Запись терминов по мере их уточнения в дизайн-сессиях. Только глоссарий:
|
||||||
|
никаких деталей реализации, скетчей спецификаций или ADR.
|
||||||
|
|
||||||
|
## Термины
|
||||||
|
|
||||||
|
### Архив
|
||||||
|
Страница опубликованных постов на сайте, организованная по времени публикации.
|
||||||
|
Представляет собой **не** плоский список, а **дерево навигации**: годы → месяцы.
|
||||||
|
|
||||||
|
### Дерево навигации архива
|
||||||
|
Навигационный элемент (правая колонка страницы архива), перечисляющий годы и,
|
||||||
|
внутри каждого года, месяцы. Строится **только из фактически существующих**
|
||||||
|
периодов (постов): если в периоде нет постов — он не появляется в дереве.
|
||||||
|
Клик по месяцу показывает посты за этот месяц, клик по году — все посты за год.
|
||||||
|
|
||||||
|
### Период без даты
|
||||||
|
Группа постов, у которых не заполнена дата публикации (`published_at`).
|
||||||
|
Показывается в дереве навигации отдельной группой «Без даты».
|
||||||
|
|
||||||
|
### Текущий период (активный)
|
||||||
|
Период (год/месяц или «все посты»), выбранный сейчас в дереве навигации;
|
||||||
|
подсвечивается, чтобы модератор видел, где он находится.
|
||||||
@@ -15,15 +15,15 @@ mkdir -p ${BACKUP_DIR}
|
|||||||
|
|
||||||
echo "🔄 [$(date)] Начинаем резервное копирование VESTI..."
|
echo "🔄 [$(date)] Начинаем резервное копирование VESTI..."
|
||||||
|
|
||||||
# Проверяем, примонтирован ли Яндекс.Диск
|
# Проверяем, примонтирован ли Яндекс.Диск (не падаем, если нет — копия локально)
|
||||||
if ! mountpoint -q ${YADISK_MOUNT}; then
|
if timeout 10 mountpoint -q ${YADISK_MOUNT}; then
|
||||||
echo " ❌ ОШИБКА: Яндекс.Диск не примонтирован!"
|
YADISK_UP=1
|
||||||
exit 1
|
mkdir -p ${YADISK_TARGET} 2>/dev/null
|
||||||
|
else
|
||||||
|
echo " ⚠️ Яндекс.Диск не примонтирован — копия только локально"
|
||||||
|
YADISK_UP=0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Создаём папку для бэкапов на Яндекс.Диске (если её нет)
|
|
||||||
mkdir -p ${YADISK_TARGET} 2>/dev/null
|
|
||||||
|
|
||||||
# 1. Бэкап проекта одним архивом
|
# 1. Бэкап проекта одним архивом
|
||||||
ARCHIVE="${BACKUP_DIR}/vesti_${DATE}.tar.gz"
|
ARCHIVE="${BACKUP_DIR}/vesti_${DATE}.tar.gz"
|
||||||
echo " → Архив проекта..."
|
echo " → Архив проекта..."
|
||||||
@@ -31,9 +31,14 @@ tar czf ${ARCHIVE} -C ${PROJECT_DIR} ${INCLUDE} 2>/dev/null
|
|||||||
|
|
||||||
if [ -s "${ARCHIVE}" ]; then
|
if [ -s "${ARCHIVE}" ]; then
|
||||||
echo " ✅ Архив: $(du -h ${ARCHIVE} | cut -f1)"
|
echo " ✅ Архив: $(du -h ${ARCHIVE} | cut -f1)"
|
||||||
|
# Копирование на ЯД только если архив <= 2GiB (больше — вешает davfs/systemd1)
|
||||||
|
archive_size=$(stat -c %s "${ARCHIVE}" 2>/dev/null || echo 0)
|
||||||
|
if [ "${YADISK_UP}" = "1" ] && [ "${archive_size}" -le $((2 * 1024 * 1024 * 1024)) ]; then
|
||||||
echo " ☁️ Копирование на Яндекс.Диск..."
|
echo " ☁️ Копирование на Яндекс.Диск..."
|
||||||
cp ${ARCHIVE} ${YADISK_TARGET}/
|
timeout 3600 cp ${ARCHIVE} ${YADISK_TARGET}/ && echo " ✅ скопирован на ЯД (${YADISK_TARGET}/)"
|
||||||
echo " ✅ скопирован на ЯД (${YADISK_TARGET}/)"
|
else
|
||||||
|
echo " 📦 архив ${archive_size}B — НЕ копирую на ЯД (>2GiB или ЯД офлайн), остаётся локально"
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
echo " ❌ Ошибка создания архива!"
|
echo " ❌ Ошибка создания архива!"
|
||||||
fi
|
fi
|
||||||
@@ -42,8 +47,10 @@ fi
|
|||||||
# Локально: оставляем 7 дней
|
# Локально: оставляем 7 дней
|
||||||
find ${BACKUP_DIR} -type f -name "vesti_*" -mtime +7 -delete 2>/dev/null
|
find ${BACKUP_DIR} -type f -name "vesti_*" -mtime +7 -delete 2>/dev/null
|
||||||
|
|
||||||
# На Яндекс.Диске: оставляем 30 дней
|
# На Яндекс.Диске: оставляем 30 дней (только если смонтирован)
|
||||||
find ${YADISK_TARGET} -type f -name "vesti_*" -mtime +30 -delete 2>/dev/null
|
if [ "${YADISK_UP}" = "1" ]; then
|
||||||
|
timeout 60 find ${YADISK_TARGET} -type f -name "vesti_*" -mtime +30 -delete 2>/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
echo "✅ [$(date)] Резервное копирование завершено!"
|
echo "✅ [$(date)] Резервное копирование завершено!"
|
||||||
|
|
||||||
|
|||||||
+22
-3
@@ -24,16 +24,33 @@ def classify_text(post: dict) -> dict:
|
|||||||
пробуем LLM-уточнение (relevance/interest/summary). При недоступности LLM —
|
пробуем LLM-уточнение (relevance/interest/summary). При недоступности LLM —
|
||||||
фолбэк: direction из словаря, relevance=low, interest=1, classified=False.
|
фолбэк: direction из словаря, relevance=low, interest=1, classified=False.
|
||||||
|
|
||||||
|
Если у поста задан source_slug с правилом SOURCE_RULES (например, lwn → tech) —
|
||||||
|
направление принудительное, LLM не вызывается (детерминированно, быстро).
|
||||||
|
|
||||||
СВОЙ контент (is_own=1): «сильный кандидат» — при словарном попадании
|
СВОЙ контент (is_own=1): «сильный кандидат» — при словарном попадании
|
||||||
relevance=critical, classified=True даже без LLM (фолбэк 'dict-own').
|
relevance=critical, classified=True даже без LLM (фолбэк 'dict-own').
|
||||||
Если словарь не дал направление — классификации нет, пост не теряется.
|
Если словарь не дал направление — классификации нет, пост не теряется.
|
||||||
|
|
||||||
post: {"text": str, "views": int, "reactions_total": int, "is_own": int, ...}
|
post: {"text": str, "views": int, "reactions_total": int, "is_own": int, "source_slug": str|None, ...}
|
||||||
"""
|
"""
|
||||||
text = (post.get("text") or "").strip()
|
text = (post.get("text") or "").strip()
|
||||||
views = int(post.get("views") or 0)
|
views = int(post.get("views") or 0)
|
||||||
reactions = int(post.get("reactions_total") or 0)
|
reactions = int(post.get("reactions_total") or 0)
|
||||||
is_own = int(post.get("is_own") or 0) == 1
|
is_own = int(post.get("is_own") or 0) == 1
|
||||||
|
source_slug = post.get("source_slug")
|
||||||
|
|
||||||
|
# правило источника (SOURCE_RULES): принудительное направление, без LLM
|
||||||
|
from classifier.keywords import source_directions
|
||||||
|
forced = source_directions(source_slug)
|
||||||
|
if forced:
|
||||||
|
return {
|
||||||
|
"direction": forced[0],
|
||||||
|
"relevance": "high",
|
||||||
|
"interest": 3,
|
||||||
|
"summary": "",
|
||||||
|
"classified": True,
|
||||||
|
"method": "source-rule",
|
||||||
|
}
|
||||||
|
|
||||||
direction = find_direction(text)
|
direction = find_direction(text)
|
||||||
if not direction:
|
if not direction:
|
||||||
@@ -170,7 +187,9 @@ def classify_posts_in_db(db_path: str, limit: int = 200, direction: str | None =
|
|||||||
where += f" AND direction IS NULL" # не классифицированы в этом направлении
|
where += f" AND direction IS NULL" # не классифицированы в этом направлении
|
||||||
|
|
||||||
rows = cur.execute(
|
rows = cur.execute(
|
||||||
f"SELECT id, text, views, reactions_total, is_own FROM posts {where} ORDER BY is_own DESC, id LIMIT ?",
|
f"SELECT p.id, p.text, p.views, p.reactions_total, p.is_own, s.slug AS source_slug "
|
||||||
|
f"FROM posts p LEFT JOIN sources s ON s.id = p.source_id {where} "
|
||||||
|
f"ORDER BY p.is_own DESC, p.id LIMIT ?",
|
||||||
(limit,),
|
(limit,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
||||||
@@ -188,7 +207,7 @@ def classify_posts_in_db(db_path: str, limit: int = 200, direction: str | None =
|
|||||||
)
|
)
|
||||||
# мультинаправления: все словарные попадания → classifications (для fan-out)
|
# мультинаправления: все словарные попадания → classifications (для fan-out)
|
||||||
from classifier.keywords import find_directions_all
|
from classifier.keywords import find_directions_all
|
||||||
dirs_all = find_directions_all(row["text"] or "")
|
dirs_all = find_directions_all(row["text"] or "", row["source_slug"])
|
||||||
if res["direction"] and res["direction"] not in dirs_all:
|
if res["direction"] and res["direction"] not in dirs_all:
|
||||||
dirs_all.append(res["direction"])
|
dirs_all.append(res["direction"])
|
||||||
for d in dirs_all:
|
for d in dirs_all:
|
||||||
|
|||||||
+29
-4
@@ -93,9 +93,28 @@ import re
|
|||||||
# Направление по умолчанию, если ничего не подошло
|
# Направление по умолчанию, если ничего не подошло
|
||||||
DEFAULT_DIRECTION = "linux" # прототип: все посты про linux окружение
|
DEFAULT_DIRECTION = "linux" # прототип: все посты про linux окружение
|
||||||
|
|
||||||
|
# Правила по источнику (slug в sources.yaml): принудительное направление
|
||||||
|
# для всего контента источника, независимо от словаря/LLM.
|
||||||
|
# LWN — технологическое СМИ: весь контент → tech.
|
||||||
|
SOURCE_RULES: dict[str, list[str]] = {
|
||||||
|
"lwn": ["tech"],
|
||||||
|
}
|
||||||
|
|
||||||
def find_directions_all(text: str) -> list[str]:
|
|
||||||
"""Возвращает ВСЕ направления, которым соответствует текст (для мультинаправлений/fan-out)."""
|
def source_directions(source_slug: str | None) -> list[str]:
|
||||||
|
"""Направления, принудительно назначенные источнику (SOURCE_RULES)."""
|
||||||
|
return list(SOURCE_RULES.get(source_slug or "", []))
|
||||||
|
|
||||||
|
|
||||||
|
def find_directions_all(text: str, source_slug: str | None = None) -> list[str]:
|
||||||
|
"""Возвращает ВСЕ направления, которым соответствует текст (для мультинаправлений/fan-out).
|
||||||
|
|
||||||
|
Если для источника задано правило SOURCE_RULES — возвращает его направления
|
||||||
|
(принудительно), иначе — словарные попадания.
|
||||||
|
"""
|
||||||
|
forced = source_directions(source_slug)
|
||||||
|
if forced:
|
||||||
|
return forced
|
||||||
text_low = (text or "").lower()
|
text_low = (text or "").lower()
|
||||||
hits = set()
|
hits = set()
|
||||||
for direction, cfg in DIRECTIONS.items():
|
for direction, cfg in DIRECTIONS.items():
|
||||||
@@ -112,8 +131,14 @@ def find_directions_all(text: str) -> list[str]:
|
|||||||
return list(hits)
|
return list(hits)
|
||||||
|
|
||||||
|
|
||||||
def find_direction(text: str) -> str | None:
|
def find_direction(text: str, source_slug: str | None = None) -> str | None:
|
||||||
"""Возвращает направление, если текст совпал со словарём (иначе None)."""
|
"""Возвращает направление: правило источника (SOURCE_RULES) или словарное.
|
||||||
|
|
||||||
|
Принудительное правило источника имеет приоритет над словарём.
|
||||||
|
"""
|
||||||
|
forced = source_directions(source_slug)
|
||||||
|
if forced:
|
||||||
|
return forced[0]
|
||||||
dirs = find_directions_all(text)
|
dirs = find_directions_all(text)
|
||||||
if dirs:
|
if dirs:
|
||||||
return dirs[0]
|
return dirs[0]
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "change_me")
|
|||||||
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://127.0.0.1:11434")
|
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://127.0.0.1:11434")
|
||||||
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "qwen3:8b-nothink")
|
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "qwen3:8b-nothink")
|
||||||
|
|
||||||
|
# RSS-краулер: честный User-Agent (некоторые издатели режут дефолтные), таймаут
|
||||||
|
RSS_USER_AGENT = os.getenv("RSS_USER_AGENT", "vesti-rss/0.1 (+https://vesti.nixg.ru)")
|
||||||
|
RSS_DEFAULT_TIMEOUT = float(os.getenv("RSS_DEFAULT_TIMEOUT", "20"))
|
||||||
|
|
||||||
# Направления (категории)
|
# Направления (категории)
|
||||||
DIRECTIONS = ["tech", "politics", "games", "electronics", "llm", "linux"]
|
DIRECTIONS = ["tech", "politics", "games", "electronics", "llm", "linux"]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# VESTI — фаза 1: сбор кандидатов с источников в очередь (posts).
|
||||||
|
# Запускает telegram_crawler и rss_crawler по включённым источникам;
|
||||||
|
# классификация НЕ выполняется — новые посты уходят в очередь (classified=0),
|
||||||
|
# которую разбирает crawler.worker (фаза 3, параллельно).
|
||||||
|
#
|
||||||
|
# Запуск: python -m crawler.crawl_sources --all
|
||||||
|
# python -m crawler.crawl_sources --crawler rss # только RSS
|
||||||
|
import argparse
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
BASE = Path(__file__).resolve().parent.parent
|
||||||
|
PY = str(BASE / ".venv" / "bin" / "python")
|
||||||
|
|
||||||
|
|
||||||
|
def run_crawler(mod: str, extra: list[str] | None = None) -> int:
|
||||||
|
"""Запускает модуль-краулер в подпроцессе. Возвращает exit code."""
|
||||||
|
cmd = [PY, "-m", mod] + (extra or [])
|
||||||
|
print(f"--- {mod} {' '.join(extra or [])} ---", flush=True)
|
||||||
|
return subprocess.run(cmd, cwd=str(BASE)).returncode
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description="VESTI фаза 1: сбор кандидатов с источников в очередь")
|
||||||
|
ap.add_argument("--all", action="store_true", help="сбор со всех включённых краулеров")
|
||||||
|
ap.add_argument("--crawler", choices=["telegram", "rss"], help="только один тип краулера")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
if not args.all and not args.crawler:
|
||||||
|
ap.print_help()
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
rc = 0
|
||||||
|
if args.all or args.crawler == "telegram":
|
||||||
|
rc |= run_crawler("crawler.telegram_crawler", ["--all"])
|
||||||
|
if args.all or args.crawler == "rss":
|
||||||
|
rc |= run_crawler("crawler.rss_crawler", ["--all"])
|
||||||
|
sys.exit(rc)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
# VESTI — воркер (фаза 3): параллельная обработка очереди кандидатов.
|
||||||
|
# ThreadPoolExecutor(N): каждый воркер атомарно забирает пост из очереди
|
||||||
|
# (posts WHERE classified=0), классифицирует (keywords → Ollama), пишет
|
||||||
|
# direction/relevance/interest/summary, ставит classified=1. При сбое — возвращает
|
||||||
|
# в очередь (classified=0). Очередь — сама таблица posts, отдельная таблица не нужна.
|
||||||
|
#
|
||||||
|
# Запуск: python -m crawler.worker --workers 8 --limit 200
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from config import DB_PATH, ensure_dirs
|
||||||
|
from classifier.classify import classify_text, call_ollama # noqa: F401 (переиспользуем)
|
||||||
|
|
||||||
|
# Лимит одновременных LLM-запросов: локальная Ollama (qwen3:8b на CPU/GPU) держит
|
||||||
|
# один слот — параллельные 8 запросов = каждый ждёт >CLASSIFY_TIMEOUT и падает.
|
||||||
|
# 8 воркеров = 8 потоков, но к LLM пускаем максимум N (обычно 2) одновременных.
|
||||||
|
LLM_SEM = threading.BoundedSemaphore(int(os.getenv("MAX_CONCURRENT_LLM", "2")))
|
||||||
|
|
||||||
|
|
||||||
|
def claim_post(conn: sqlite3.Connection) -> sqlite3.Row | None:
|
||||||
|
"""Атомарный claim поста из очереди.
|
||||||
|
|
||||||
|
BEGIN IMMEDIATE блокирует других воркеров на время claim; SELECT→UPDATE
|
||||||
|
гарантирует, что строку заберёт ровно один воркер (classified: 0 → -1).
|
||||||
|
Возвращает строку поста или None (очередь пуста).
|
||||||
|
"""
|
||||||
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
|
try:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT p.id, p.text, p.views, p.reactions_total, p.is_own, s.slug AS source_slug "
|
||||||
|
"FROM posts p LEFT JOIN sources s ON s.id = p.source_id "
|
||||||
|
"WHERE p.classified IS NULL OR p.classified=0 "
|
||||||
|
"ORDER BY p.is_own DESC, p.id LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
if row:
|
||||||
|
conn.execute("UPDATE posts SET classified=-1 WHERE id=?", (row["id"],))
|
||||||
|
conn.commit()
|
||||||
|
return row
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def process_post(conn: sqlite3.Connection, post_id: int, text: str,
|
||||||
|
views: int, reactions: int, is_own: int, source_slug: str | None = None) -> dict:
|
||||||
|
"""Классифицирует пост и пишет результат. Возвращает result classify_text.
|
||||||
|
|
||||||
|
При исключении — пост возвращается в очередь (classified=0), НЕ теряется.
|
||||||
|
Мультинаправления из словаря (find_directions_all) — в classifications.
|
||||||
|
source_slug (правило SOURCE_RULES, напр. lwn→tech) — направление принудительное.
|
||||||
|
"""
|
||||||
|
res = classify_text({"text": text, "views": views,
|
||||||
|
"reactions_total": reactions, "is_own": is_own,
|
||||||
|
"source_slug": source_slug})
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE posts SET direction=?, relevance=?, interest=?, summary=?, classified=? WHERE id=?",
|
||||||
|
(res["direction"], res["relevance"], res["interest"], res["summary"],
|
||||||
|
1 if res["classified"] else 0, post_id),
|
||||||
|
)
|
||||||
|
# мультинаправления: все словарные попадания → classifications (для fan-out)
|
||||||
|
from classifier.keywords import find_directions_all
|
||||||
|
dirs_all = find_directions_all(text or "", source_slug)
|
||||||
|
if res["direction"] and res["direction"] not in dirs_all:
|
||||||
|
dirs_all.append(res["direction"])
|
||||||
|
for d in dirs_all:
|
||||||
|
exists = conn.execute(
|
||||||
|
"SELECT 1 FROM classifications WHERE post_id=? AND direction=? LIMIT 1",
|
||||||
|
(post_id, d),
|
||||||
|
).fetchone()
|
||||||
|
if not exists:
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO classifications (post_id, direction, relevance, interest, summary, model)
|
||||||
|
VALUES (?,?,?,?,?,?)""",
|
||||||
|
(post_id, d, res["relevance"], res["interest"], res["summary"],
|
||||||
|
res.get("method") or "classify"),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
def work_loop(worker_id: int, stats: dict, stop: threading.Event, limit: int):
|
||||||
|
"""Цикл воркера: claim → классификация → запись, пока очередь не пуста/лимит.
|
||||||
|
|
||||||
|
Соединение создаётся ВНУТРИ потока (SQLite: соединение привязано к thread).
|
||||||
|
"""
|
||||||
|
conn = _new_conn()
|
||||||
|
processed = 0
|
||||||
|
try:
|
||||||
|
while not stop.is_set() and processed < limit:
|
||||||
|
row = claim_post(conn)
|
||||||
|
if row is None:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
# LLM-вызовы ограничены семифором (не перегружаем Ollama)
|
||||||
|
with LLM_SEM:
|
||||||
|
res = process_post(conn, row["id"], row["text"], row["views"],
|
||||||
|
row["reactions_total"], row["is_own"],
|
||||||
|
row["source_slug"])
|
||||||
|
with stats["lock"]:
|
||||||
|
stats["processed"] += 1
|
||||||
|
if res["classified"]:
|
||||||
|
stats["classified"] += 1
|
||||||
|
if res.get("method") == "llm":
|
||||||
|
stats["llm_ok"] += 1
|
||||||
|
except Exception as e:
|
||||||
|
# вернуть в очередь — пост не теряется; зафиксировать счётчик ошибок
|
||||||
|
try:
|
||||||
|
conn.execute("UPDATE posts SET classified=0 WHERE id=?", (row["id"],))
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
with stats["lock"]:
|
||||||
|
stats["errors"] += 1
|
||||||
|
print(f"[worker {worker_id}] error post {row['id']}: {e}", flush=True)
|
||||||
|
processed += 1
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
return processed
|
||||||
|
|
||||||
|
|
||||||
|
def _new_conn() -> sqlite3.Connection:
|
||||||
|
"""Отдельное соединение для воркера (WAL — читатели/писатели параллельны)."""
|
||||||
|
ensure_dirs()
|
||||||
|
conn = sqlite3.connect(DB_PATH, timeout=30)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
conn.execute("PRAGMA busy_timeout=10000")
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def run(workers: int = 8, limit: int = 200):
|
||||||
|
"""Параллельная обработка очереди кандидатов. Возвращает (processed, classified, llm_ok, errors)."""
|
||||||
|
stats = {"processed": 0, "classified": 0, "llm_ok": 0, "errors": 0, "lock": threading.Lock()}
|
||||||
|
stop = threading.Event()
|
||||||
|
|
||||||
|
# каждый воркер сам создаёт соединение внутри своего потока
|
||||||
|
print(f"workers={workers}, limit={limit}")
|
||||||
|
with ThreadPoolExecutor(max_workers=workers) as ex:
|
||||||
|
futs = {ex.submit(work_loop, i, stats, stop, limit): i
|
||||||
|
for i in range(workers)}
|
||||||
|
try:
|
||||||
|
for f in as_completed(futs):
|
||||||
|
f.result()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
stop.set()
|
||||||
|
print("\nостановлено (Ctrl+C)")
|
||||||
|
return stats["processed"], stats["classified"], stats["llm_ok"], stats["errors"]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description="VESTI worker — параллельная обработка очереди кандидатов")
|
||||||
|
ap.add_argument("--workers", type=int, default=8)
|
||||||
|
ap.add_argument("--limit", type=int, default=200)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
t0 = time.monotonic()
|
||||||
|
proc, cls, llm, err = run(workers=args.workers, limit=args.limit)
|
||||||
|
print(f"Обработано: {proc}, классифицировано: {cls}, через LLM: {llm}, ошибок: {err}, время: {time.monotonic()-t0:.1f}s", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# Issue Tracker
|
||||||
|
|
||||||
|
**Tracker:** Gitea (self-hosted, https://gitea.nixg.ru)
|
||||||
|
**Repository:** `estorozhenko/vesti` (mirror from gitverse.ru/kpa39l/vesti)
|
||||||
|
**Web UI:** https://gitea.nixg.ru/estorozhenko/vesti
|
||||||
|
|
||||||
|
This is a custom ("Other") tracker configuration for the Matt Pocock engineering skills (`to-tickets`, `to-spec`, `triage`). Gitea is not natively supported by `/setup-matt-pocock-skills`, so the workflow is recorded here as freeform prose.
|
||||||
|
|
||||||
|
## How to publish issues
|
||||||
|
|
||||||
|
Use the **`tea` CLI** (v0.16.0, at `~/.local/bin/tea`) with the login **`gitea.nixg-full`**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
|
|
||||||
|
# Create an issue
|
||||||
|
tea issues create --login gitea.nixg-full --repo estorozhenko/vesti \
|
||||||
|
--title "Ticket title" \
|
||||||
|
--description "Ticket body (use the to-tickets per-ticket template)" \
|
||||||
|
--labels "ready-for-agent"
|
||||||
|
|
||||||
|
# List issues
|
||||||
|
tea issues list --login gitea.nixg-full --repo estorozhenko/vesti -o simple
|
||||||
|
|
||||||
|
# Show one issue (with comments)
|
||||||
|
tea issues --login gitea.nixg-full --repo estorozhenko/vesti 42 --comments
|
||||||
|
|
||||||
|
# Add a comment
|
||||||
|
tea comment --login gitea.nixg-full --repo estorozhenko/vesti 42 "comment"
|
||||||
|
|
||||||
|
# Close / reopen
|
||||||
|
tea issues close --login gitea.nixg-full --repo estorozhenko/vesti 42
|
||||||
|
```
|
||||||
|
|
||||||
|
**Alternative:** the `gitea` MCP server (gitea-mcp, 72 tools) is configured in Hermes `mcp_servers.gitea` — same instance, same token. Prefer MCP tools in agent sessions, `tea` for quick shell checks.
|
||||||
|
|
||||||
|
## Repository and credentials
|
||||||
|
|
||||||
|
- Instance: `https://gitea.nixg.ru` (API v1.26.2, external VPS 5.129.217.146)
|
||||||
|
- Owner/repo: `estorozhenko/vesti` (mirror of `https://gitverse.ru/kpa39l/vesti.git`, issues enabled: yes)
|
||||||
|
- Login (tea): `gitea.nixg-full` — token `hermes-mcp-full`
|
||||||
|
- Token source: `/opt/hermes/.hermes/secrets/git-tokens.env` (`GITEA_NIXG_TOKEN`); also in Hermes `config.yaml` → `mcp_servers.gitea` env `GITEA_TOKEN`.
|
||||||
|
- Git push: to the **origin** (gitverse.ru) via SSH key `/home/estorozhenko/.ssh/gitverse` — the Gitea repo is a read-only mirror and is NOT the push target.
|
||||||
|
|
||||||
|
## Triage labels (created)
|
||||||
|
|
||||||
|
| Label | Color | ID |
|
||||||
|
|---|---|---|
|
||||||
|
| `needs-triage` | `#e11d21` | 6 |
|
||||||
|
| `needs-info` | `#fbca04` | 7 |
|
||||||
|
| `ready-for-agent` | `#0e8a16` | 8 |
|
||||||
|
| `ready-for-human` | `#006b75` | 9 |
|
||||||
|
| `wontfix` | `#ffffff` | 10 |
|
||||||
|
|
||||||
|
Use `ready-for-agent` for tickets published by `to-tickets`.
|
||||||
|
|
||||||
|
## Blocking edges
|
||||||
|
|
||||||
|
Gitea has no native issue-blocking links. Follow the `to-tickets` rule for trackers without native blocking: publish tickets in dependency order (blockers first), and set each ticket's **"Blocked by"** section to the blocking issues' real identifiers (e.g. `#12`, `#13`).
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Tickets for the VESTI project live in `estorozhenko/vesti`. Local fallback: `.scratch/<feature>/issues/` in this project when no Gitea write access.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-09-20
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# Design — archive-navigation
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
- Роут: `GET /published` (`web/app.py:735`), шаблон `web/templates/published.html` (42 строки).
|
||||||
|
- Дата: `posts.published_at TEXT` — ISO 8601 с таймзоной (`2026-09-08T08:02:45+00:00`).
|
||||||
|
- Сейчас: `LIMIT 100` без пагинации, без фильтров.
|
||||||
|
- Фронтенд: локальный Bootstrap (`web/static/bootstrap.min.css`), без JS-фреймворков,
|
||||||
|
htmx запрещён (AGENT.MD). Паттерн фильтра по направлениям — query-параметры (`_cand_back`).
|
||||||
|
|
||||||
|
## 1. Хелпер дерева навигации
|
||||||
|
|
||||||
|
В `web/app.py`, рядом с `_db()`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
MONTH_NAMES_RU = ("", "Янв", "Фев", "Мар", "Апр", "Май", "Июн",
|
||||||
|
"Июл", "Авг", "Сен", "Окт", "Ноя", "Дек")
|
||||||
|
|
||||||
|
def _archive_tree(conn):
|
||||||
|
"""Дерево навигации архива: [{year, months:[{m, label, n}], n}] + {no_date:n}.
|
||||||
|
Строится только из существующих периодов; пустые месяцы отсутствуют."""
|
||||||
|
rows = conn.execute(
|
||||||
|
"""SELECT strftime('%Y', published_at) y,
|
||||||
|
strftime('%m', published_at) m, COUNT(*) n
|
||||||
|
FROM posts WHERE status='published' AND published_at IS NOT NULL
|
||||||
|
AND published_at != ''
|
||||||
|
GROUP BY y, m ORDER BY y DESC, m DESC"""
|
||||||
|
).fetchall()
|
||||||
|
years, cur = [], None
|
||||||
|
for r in rows:
|
||||||
|
if cur is None or cur["year"] != r["y"]:
|
||||||
|
cur = {"year": r["y"], "months": [], "n": 0}
|
||||||
|
years.append(cur)
|
||||||
|
cur["months"].append({"m": r["m"], "label": f"{MONTH_NAMES_RU[int(r['m'])]} {r['y']}",
|
||||||
|
"n": r["n"]})
|
||||||
|
cur["n"] += r["n"]
|
||||||
|
no_date = conn.execute(
|
||||||
|
"""SELECT COUNT(*) FROM posts
|
||||||
|
WHERE status='published' AND (published_at IS NULL OR published_at='')"""
|
||||||
|
).fetchone()[0]
|
||||||
|
return {"years": years, "no_date": no_date}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Сортировка: годы и месяцы — по убыванию (свежие сверху, Q9).
|
||||||
|
- Пустой период исключается самой группировкой `GROUP BY` — ровно как Hugo-архивы
|
||||||
|
(`site.RegularPages | groupBy "Date.Year"`), где календарь не итерируется (Q8).
|
||||||
|
|
||||||
|
## 2. Роут `GET /published`
|
||||||
|
|
||||||
|
```python
|
||||||
|
ARCHIVE_PAGE_SIZE = 10
|
||||||
|
|
||||||
|
@app.get("/published", response_class=HTMLResponse)
|
||||||
|
def published(request: Request, year: str = "", month: str = "", page: int = 1):
|
||||||
|
_require_auth(request)
|
||||||
|
page = max(1, page)
|
||||||
|
conn = _db()
|
||||||
|
tree = _archive_tree(conn)
|
||||||
|
|
||||||
|
where, params = ["p.status='published'"], []
|
||||||
|
if year:
|
||||||
|
where.append("strftime('%Y', p.published_at) = ?"); params.append(year)
|
||||||
|
if month:
|
||||||
|
where.append("strftime('%m', p.published_at) = ?"); params.append(month)
|
||||||
|
# год без месяца = все посты за год; "Без даты" не попадает в year/month
|
||||||
|
if not year and not month:
|
||||||
|
where.append("(p.published_at IS NOT NULL AND p.published_at != '')")
|
||||||
|
|
||||||
|
total = conn.execute(f"SELECT COUNT(*) FROM posts p WHERE {' AND '.join(where)}",
|
||||||
|
params).fetchone()[0]
|
||||||
|
pages = max(1, (total + ARCHIVE_PAGE_SIZE - 1) // ARCHIVE_PAGE_SIZE)
|
||||||
|
page = min(page, pages)
|
||||||
|
|
||||||
|
rows = conn.execute(
|
||||||
|
f"""SELECT p.*, s.name source_name,
|
||||||
|
pub.tg_message_id, pub.views pub_views, pub.bundle_path, pub.distributed_dirs
|
||||||
|
FROM posts p
|
||||||
|
LEFT JOIN sources s ON s.id=p.source_id
|
||||||
|
LEFT JOIN published pub ON pub.post_id=p.id
|
||||||
|
WHERE {' AND '.join(where)}
|
||||||
|
ORDER BY p.published_at DESC LIMIT ? OFFSET ?""",
|
||||||
|
params + [ARCHIVE_PAGE_SIZE, (page - 1) * ARCHIVE_PAGE_SIZE]
|
||||||
|
).fetchall()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
html = tpl.get_template("published.html").render(
|
||||||
|
posts=rows, tree=tree, year=year, month=month,
|
||||||
|
page=page, pages=pages, total=total,
|
||||||
|
)
|
||||||
|
return HTMLResponse(html)
|
||||||
|
```
|
||||||
|
|
||||||
|
- Блок «Без даты» показывается как ссылка `/?year=&month=`? Нет — отдельный
|
||||||
|
фильтр: кнопка «Без даты» (query `nodate=1`), тоже с деревом и пагинацией.
|
||||||
|
(Добавлено в spec ниже — сценарий «Без даты»).
|
||||||
|
|
||||||
|
## 3. Шаблон `published.html`
|
||||||
|
|
||||||
|
- Обёртка: `row` → `col-lg-9` (список) + `col-lg-3` (дерево, `position-sticky top-2`).
|
||||||
|
- Дерево:
|
||||||
|
- ссылка «📄 Все посты» → `/published` (активна, если нет year/month/nodate).
|
||||||
|
- по годам: `<h6>2026 ▾</h6>` + `<ul>` месяцев; месяц — ссылка
|
||||||
|
`/published?year=2026&month=09`, счётчик `(4)`. Активный месяц подсвечен
|
||||||
|
(`active-row`-стиль, как в кандидатах).
|
||||||
|
- на мобильных: дерево в `collapse` (кнопка «🗂 Архив» + `data-bs-toggle`).
|
||||||
|
- Список: карточки как сейчас, но посты только за выбранный период.
|
||||||
|
- Пагинация снизу: Bootstrap `pagination`, `page-item active` для текущей,
|
||||||
|
prev/next, номера 1..pages (с сохранением year/month/nodate).
|
||||||
|
- Пустой период: `alert-info` «Нет постов за этот период» (дерево всё равно видно).
|
||||||
|
|
||||||
|
## 4. Пагинация
|
||||||
|
|
||||||
|
- Bootstrap HTML, без JS: `?year=2026&month=09&page=N`.
|
||||||
|
- Текущая страница — `active`, prev/next — disabled на границах.
|
||||||
|
- total = число постов в периоде; pages = ceil(total/10).
|
||||||
|
|
||||||
|
## 5. Проверка
|
||||||
|
|
||||||
|
- `openspec validate archive-navigation` — чисто.
|
||||||
|
- `curl -s 'http://127.0.0.1:8400/published?year=2026&month=09'` — видно 4 поста.
|
||||||
|
- `curl -s 'http://127.0.0.1:8400/published?month=07'` — 2 поста.
|
||||||
|
- В HTML дерева нет месяца без постов (только 07, 08, 09) — «февраль отсутствует».
|
||||||
|
- `?page=1`..N — пагинация с номерами; вне диапазона — клампится к последней.
|
||||||
|
- Мобильная вёрстка: сужение окна → колонка сворачивается.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# archive-navigation
|
||||||
|
|
||||||
|
Страница «Опубликовано» (`/published`) — настоящий архив с навигацией по годам и месяцам.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Сейчас `/published` — просто плоский список последних 100 опубликованных постов
|
||||||
|
без какой-либо навигации. С ростом архива найти пост по дате невозможно: нет ни
|
||||||
|
периодов, ни фильтров, ни разбивки. Это противоречит назначению страницы —
|
||||||
|
«архив опубликованного».
|
||||||
|
|
||||||
|
Пользователь хочет классический архив: дерево лет/месяцев слева или справа,
|
||||||
|
посты — за выбранный период. Чтобы не занимать место вверху страницы
|
||||||
|
(навигация вверху слишком громоздкая), выбрана правая колонка (Q1–Q10,
|
||||||
|
grill-with-docs 2026-09-20, записано в AGENT.MD).
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- **web/app.py**, роут `GET /published`:
|
||||||
|
- принимает query-параметры `year` (YYYY), `month` (MM), `page` (по умолчанию 1).
|
||||||
|
- строит дерево навигации: `SELECT strftime('%Y', published_at) y, strftime('%m', published_at) m, COUNT(*) n FROM posts WHERE status='published' GROUP BY y, m` → группировка по годам, внутри — месяцы с количеством. Группа «Без даты» — посты с пустым `published_at` (характер: могут появиться; сейчас 0).
|
||||||
|
- фильтрует посты по выбранному периоду: `year` + `month` (все посты за год, если месяц не указан), либо «Все посты» (без параметров).
|
||||||
|
- пагинация по 10 постов (LIMIT 10 OFFSET).
|
||||||
|
- рендер в `published.html` с деревом навигации, списком постов, подсветкой активного периода.
|
||||||
|
- **web/templates/published.html**:
|
||||||
|
- правая колонка (Bootstrap `col-md-3` + sticky) — дерево годов/месяцев со счётчиками, ссылка «Все посты», группа «Без даты»; пустые периоды не выводятся.
|
||||||
|
- слева — список постов за выбранный период + Bootstrap pagination по 10 (prev/next + номера).
|
||||||
|
- на мобильных — колонка сворачивается (Bootstrap collapse).
|
||||||
|
- **web/app.py**: хелпер построения дерева (чистая функция, тестируемая).
|
||||||
|
- Обновить `STATUS.md`, `CONTEXT.md` (глоссарий уже добавлен).
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
- Откат: `git checkout -- web/app.py web/templates/published.html` (change фиксирует
|
||||||
|
состояние до правок), рестарт vesti-web.
|
||||||
|
|
||||||
|
## Затронутые сервисы/порты
|
||||||
|
|
||||||
|
- vesti-web (:8400), внешний https://vesti.nixg.ru. БД SQLite — только чтение
|
||||||
|
через существующий `_db()` (WAL + busy_timeout).
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# Spec — archive-navigation
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Архив с навигацией по годам и месяцам
|
||||||
|
|
||||||
|
Страница «Опубликовано» (`/published`) показывает опубликованные посты,
|
||||||
|
сгруппированные по времени публикации, с навигацией по годам и месяцам
|
||||||
|
в правой колонке. Посты показываются за выбранный период (месяц, год или все).
|
||||||
|
|
||||||
|
#### Scenario: Открытие архива без параметров
|
||||||
|
|
||||||
|
Given модератор открывает `/published` без параметров
|
||||||
|
Then показываются опубликованные посты (страница 1, по 10)
|
||||||
|
And в правой колонке — дерево навигации: годы, внутри — месяцы со счётчиками
|
||||||
|
And дерево содержит только периоды, в которых есть посты
|
||||||
|
And сверху дерева есть ссылка «Все посты»
|
||||||
|
|
||||||
|
#### Scenario: Выбор месяца
|
||||||
|
|
||||||
|
Given в дереве навигации есть месяц «Сен 2026» (4 поста)
|
||||||
|
When модератор кликает «Сен 2026»
|
||||||
|
Then показываются только посты за сентябрь 2026 (4 поста)
|
||||||
|
And месяц подсвечен в дереве как активный
|
||||||
|
|
||||||
|
#### Scenario: Выбор года (без месяца)
|
||||||
|
|
||||||
|
Given модератор на странице архива
|
||||||
|
When модератор кликает на год «2026»
|
||||||
|
Then показываются все посты за 2026 год целиком (без разбивки по месяцам)
|
||||||
|
And год подсвечен в дереве как активный
|
||||||
|
|
||||||
|
#### Scenario: Пустой период не появляется в навигации
|
||||||
|
|
||||||
|
Given в архиве нет постов за февраль 2026
|
||||||
|
When модератор смотрит дерево навигации
|
||||||
|
Then в дереве нет ссылки/узла «Фев 2026»
|
||||||
|
(Периоды в дереве порождаются только постами, как в Hugo-архивах)
|
||||||
|
|
||||||
|
### Requirement: Пагинация по 10 постов
|
||||||
|
|
||||||
|
Список постов на странице архива разбивается постранично, по 10 постов
|
||||||
|
на страницу. Навигация — Bootstrap-пагинация с номерами и prev/next,
|
||||||
|
сохраняющая выбранный период.
|
||||||
|
|
||||||
|
#### Scenario: Переход на следующую страницу
|
||||||
|
|
||||||
|
Given в выбранном периоде 25 постов (3 страницы)
|
||||||
|
When модератор кликает «2» в пагинации
|
||||||
|
Then показываются посты 11–20 за тот же период
|
||||||
|
And адрес содержит `page=2` и сохранённые `year`/`month`
|
||||||
|
|
||||||
|
#### Scenario: Пагинация за пределами диапазона
|
||||||
|
|
||||||
|
Given в периоде 1 страница постов
|
||||||
|
When модератор запрашивает `page=5`
|
||||||
|
Then показывается последняя (единственная) страница, без ошибки
|
||||||
|
|
||||||
|
### Requirement: Группа «Без даты»
|
||||||
|
|
||||||
|
Посты со статусом «опубликован», у которых не заполнена дата публикации,
|
||||||
|
не теряются: они показываются в отдельной группе «Без даты» в дереве
|
||||||
|
навигации и по клику — список этих постов.
|
||||||
|
|
||||||
|
#### Scenario: Пост без даты виден в архиве
|
||||||
|
|
||||||
|
Given существует опубликованный пост с пустым `published_at`
|
||||||
|
When модератор открывает `/published`
|
||||||
|
Then в дереве навигации есть ссылка «Без даты»
|
||||||
|
And по клику показываются посты без даты (с пагинацией)
|
||||||
|
|
||||||
|
### Requirement: Правая колонка навигации
|
||||||
|
|
||||||
|
Дерево навигации располагается в правой колонке страницы и остаётся
|
||||||
|
видимым при прокрутке (position-sticky). На узких экранах колонка
|
||||||
|
сворачивается под кнопку «🗂 Архив» (Bootstrap collapse).
|
||||||
|
|
||||||
|
#### Scenario: Правая колонка при прокрутке
|
||||||
|
|
||||||
|
Given модератор прокручивает длинный список постов
|
||||||
|
Then дерево навигации остаётся видимым справа (sticky)
|
||||||
|
|
||||||
|
#### Scenario: Мобильный вид
|
||||||
|
|
||||||
|
Given модератор открывает архив на узком экране (< 768px)
|
||||||
|
Then дерево навигации свёрнуто под кнопку «🗂 Архив» и раскрывается по клику
|
||||||
|
|
||||||
|
### Requirement: Пустой период — заглушка
|
||||||
|
|
||||||
|
Если в выбранном периоде нет постов, показывается сообщение
|
||||||
|
«Нет постов за этот период», дерево навигации остаётся доступным.
|
||||||
|
|
||||||
|
#### Scenario: Запрос пустого периода напрямую
|
||||||
|
|
||||||
|
Given модератор открывает `/published?year=2025&month=01` (нет постов)
|
||||||
|
Then показывается заглушка «Нет постов за этот период»
|
||||||
|
And дерево навигации справа доступно для перехода
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Задачи
|
||||||
|
|
||||||
|
- [x] web/app.py: хелпер `_archive_tree(conn)` — дерево годов/месяцев GROUP BY + счётчики + «Без даты»
|
||||||
|
- [x] web/app.py: роут `GET /published` — параметры year/month/page, фильтр периода, пагинация по 10 (LIMIT/OFFSET, клампинг)
|
||||||
|
- [x] web/templates/published.html: правая колонка (col-lg-3 sticky) — дерево навигации, «Все посты», «Без даты»
|
||||||
|
- [x] web/templates/published.html: список постов за период + Bootstrap pagination (prev/next + номера)
|
||||||
|
- [x] web/templates/published.html: collapse на мобильных (кнопка «🗂 Архив»)
|
||||||
|
- [x] web/app.py: `_archive_q` + `tpl.globals["_published_q"]` — построение query-строки с сохранением фильтров
|
||||||
|
- [x] Тест: py_compile + рендер шаблона (дерево, пагинация, active-класс) через .venv
|
||||||
|
- [x] openspec validate archive-navigation — чисто (только стилевые SHALL/MUST warned, спека по-русски)
|
||||||
|
- [x] Рестарт vesti-web, проверка: /published (дерево 2026, 8 постов, без дублей), ?year=2026&month=09 (4), month=08 (2), 2025 (заглушка), пагинация скрыта при 1 стр, битые /published? — 0
|
||||||
|
- [ ] Обновить STATUS.md / TODO.md
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-09-16
|
||||||
|
skip_specs: true
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
# extract-post-sources
|
||||||
|
|
||||||
|
## 1. sources/extract.py
|
||||||
|
|
||||||
|
```python
|
||||||
|
import re
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
_MAX_PER_POST = 5
|
||||||
|
|
||||||
|
# t.me/handle, t.me/s/handle, telegram.me/handle, @handle
|
||||||
|
_TG_RE = re.compile(
|
||||||
|
r"(?:https?://)?(?:www\.)?(?:t\.me|telegram\.me)/(?:s/)?([a-zA-Z][a-zA-Z0-9_]{3,31})"
|
||||||
|
)
|
||||||
|
_AT_RE = re.compile(r"(?<!\w)@([a-zA-Z][a-zA-Z0-9_]{3,31})\b")
|
||||||
|
# внешние http(s)-ссылки (не t.me/telegram.me)
|
||||||
|
_URL_RE = re.compile(r"https?://([^\s<>\"']+)", re.I)
|
||||||
|
|
||||||
|
|
||||||
|
def _domain_of(url: str) -> str:
|
||||||
|
return (urlparse(url if "://" in url else "https://" + url).netloc or "").lower()
|
||||||
|
|
||||||
|
|
||||||
|
def extract_sources_from_text(text: str, exclude_tg_url: str | None = None) -> list[dict]:
|
||||||
|
"""Ищет TG-каналы и внешние сайты в тексте. exclude_tg_url — ссылка на сам пост
|
||||||
|
(t.me/channel/postid) — её отбрасываем, это не источник, а адрес поста."""
|
||||||
|
out: list[dict] = []
|
||||||
|
seen = set()
|
||||||
|
|
||||||
|
def add(kind, address, slug_hint, domain):
|
||||||
|
key = (kind, slug_hint or domain or address)
|
||||||
|
if key in seen:
|
||||||
|
return
|
||||||
|
seen.add(key)
|
||||||
|
out.append({"kind": kind, "address": address, "slug_hint": slug_hint, "domain": domain})
|
||||||
|
|
||||||
|
text = (text or "")[:4000]
|
||||||
|
for m in _TG_RE.finditer(text):
|
||||||
|
ch = m.group(1)
|
||||||
|
if exclude_tg_url and f"/{ch}/" in exclude_tg_url:
|
||||||
|
continue # ссылка на сам пост
|
||||||
|
add("telegram", f"https://t.me/{ch}", ch, None)
|
||||||
|
for m in _AT_RE.finditer(text):
|
||||||
|
add("telegram", f"https://t.me/{m.group(1)}", m.group(1), None)
|
||||||
|
for m in _URL_RE.finditer(text):
|
||||||
|
u = m.group(1).rstrip(".,;:!?)]}\"'")
|
||||||
|
dom = _domain_of(u)
|
||||||
|
if not dom:
|
||||||
|
continue
|
||||||
|
if dom in ("t.me", "telegram.me") or dom.endswith(".t.me"):
|
||||||
|
continue # уже обработали выше
|
||||||
|
add("site", u, None, dom)
|
||||||
|
if len(out) >= _MAX_PER_POST:
|
||||||
|
break
|
||||||
|
return out[:_MAX_PER_POST]
|
||||||
|
```
|
||||||
|
|
||||||
|
Ограничение `_MAX_PER_POST=5`: чтобы не захламлять карточку десятками ссылок.
|
||||||
|
|
||||||
|
## 2. web/app.py
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sources.sources import parse_address, add_or_update_source
|
||||||
|
|
||||||
|
def _post_sources(post: dict) -> list[dict]:
|
||||||
|
"""Список источников, найденных в тексте поста: + флаг known (уже в реестре)."""
|
||||||
|
from sources.extract import extract_sources_from_text
|
||||||
|
found = extract_sources_from_text(post.get("text") or "", post.get("url"))
|
||||||
|
if not found:
|
||||||
|
return []
|
||||||
|
with db() as conn:
|
||||||
|
slugs = {r[0] for r in conn.execute("SELECT slug FROM sources")}
|
||||||
|
for f in found:
|
||||||
|
f["known"] = f["slug_hint"] in slugs
|
||||||
|
return found
|
||||||
|
```
|
||||||
|
|
||||||
|
В `candidates()` и `selected()`: `post_sources=_post_sources(sel) if sel else []`.
|
||||||
|
|
||||||
|
POST `/sources/add-from-post`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@app.post("/sources/add-from-post")
|
||||||
|
def sources_add_from_post(request: Request, address: str = Form(...),
|
||||||
|
kind: str = Form(""), crawler: str = Form("telegram"),
|
||||||
|
src: str = Form("candidates"), selected: int = Form(0), ...):
|
||||||
|
_require_auth(request)
|
||||||
|
parsed = parse_address(address, crawler)
|
||||||
|
if not parsed:
|
||||||
|
return RedirectResponse(url=_cand_back(..., did="⚠️ Не удалось разобрать адрес"), ...)
|
||||||
|
slug = parsed["slug"]
|
||||||
|
with db() as conn:
|
||||||
|
exists = conn.execute("SELECT 1 FROM sources WHERE slug=?", (slug,)).fetchone()
|
||||||
|
if exists:
|
||||||
|
return RedirectResponse(url=_cand_back(..., did=f"Источник {slug} уже в реестре"), ...)
|
||||||
|
ok, res = add_or_update_source({"name": f"Из поста: {slug}", "crawler": crawler, **parsed})
|
||||||
|
did = f"➕ Источник {res} добавлен" if ok else f"❌ {res}"
|
||||||
|
return RedirectResponse(url=_cand_back(..., did=did), ...)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Шаблоны
|
||||||
|
|
||||||
|
В candidates.html и selected.html (под бейджами, перед деталями):
|
||||||
|
|
||||||
|
```html
|
||||||
|
{% if post_sources %}
|
||||||
|
<div class="small text-muted mb-1">
|
||||||
|
🌐 Источники из поста:
|
||||||
|
{% for s in post_sources %}
|
||||||
|
{% if s.known %}
|
||||||
|
<span class="badge bg-light text-dark">@{{ s.slug_hint or s.domain }} ✓</span>
|
||||||
|
{% else %}
|
||||||
|
<form method="post" action="/sources/add-from-post" class="d-inline align-middle">
|
||||||
|
{{ hid }}
|
||||||
|
<input type="hidden" name="address" value="{{ s.address }}">
|
||||||
|
<input type="hidden" name="crawler" value="{% if s.kind == 'telegram' %}telegram{% else %}rss{% endif %}">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary py-0" title="Добавить {{ s.address }} в источники">
|
||||||
|
➕ @{{ s.slug_hint or s.domain }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
```
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# extract-post-sources
|
||||||
|
|
||||||
|
Извлечение источников новостей из текста постов (TG-каналы/сайты) и быстрое добавление в реестр.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Пользователь: нужно извлекать источники новостей из полученных постов (каналы/сайты,
|
||||||
|
откуда пришли новости), чтобы в следующий раз забирать новости напрямую из источника.
|
||||||
|
Сейчас ссылки в тексте постов никак не используются: всё приходит из sources.yaml,
|
||||||
|
а репосты/упоминания других каналов и сайтов теряются.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- **sources/extract.py** (новый): `extract_sources_from_text(text) -> list[dict]` —
|
||||||
|
находит в тексте ссылки на TG-каналы (t.me/handle, @handle, t.me/s/handle) и
|
||||||
|
внешние сайты (https://domain), исключает ссылки на сам пост (t.me/channel/postid),
|
||||||
|
дедуплицирует. Ограничение: не более 5 найденных на пост (шум).
|
||||||
|
- **web/app.py**:
|
||||||
|
- helper `_post_sources(post)` → список {kind, address, slug_hint, known} для карточки
|
||||||
|
(known = slug_hint уже есть в sources).
|
||||||
|
- В render candidates/selected передаёт `post_sources`.
|
||||||
|
- POST `/sources/add-from-post`: {address} → parse_address + add_or_update_source
|
||||||
|
(переиспользует существующий CRUD). Возвращает redirect на ту же страницу с did-сообщением.
|
||||||
|
- **web/templates/candidates.html / selected.html**: блок «🌐 Источники из поста» под бейджами:
|
||||||
|
для каждого найденного — `@handle` / `domain`, бейдж «в реестре» или кнопка «➕».
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- В карточках кандидатов/отобранных видны ссылки на внешние каналы/сайты из текста.
|
||||||
|
- Один клик — добавить источник в реестр (yaml + БД, как ручное добавление).
|
||||||
|
- Никаких LLM-вызовов — regex-извлечение, бесплатно. Дообогащение модели для
|
||||||
|
одобренных — отдельная задача (по кнопке, экономия токенов).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-09-16
|
||||||
|
skip_specs: true
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# manual-direction
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### web/app.py
|
||||||
|
|
||||||
|
`post_reclassify(post_id, request, group_by, direction, own, q, src, manual_direction: str = Form(""))`:
|
||||||
|
|
||||||
|
- передать `manual_direction` в `impl_reclassify`.
|
||||||
|
|
||||||
|
`impl_reclassify(post_id, manual_direction: str = "")`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def impl_reclassify(post_id: int, manual_direction: str = ""):
|
||||||
|
from classifier.classify import classify_text
|
||||||
|
from classifier.keywords import DIRECTIONS_CANON
|
||||||
|
conn = _db()
|
||||||
|
p = conn.execute("SELECT * FROM posts WHERE id=?", (post_id,)).fetchone()
|
||||||
|
if not p:
|
||||||
|
conn.close()
|
||||||
|
raise ValueError("notfound")
|
||||||
|
md = (manual_direction or "").strip()
|
||||||
|
if md in DIRECTIONS_CANON:
|
||||||
|
# ручное направление: без LLM, classified=1, как словарное попадание
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE posts SET direction=?, relevance=?, interest=?, summary=?, classified=1 WHERE id=?",
|
||||||
|
(md, "low", 1, "", post_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return
|
||||||
|
res = classify_text(dict(p))
|
||||||
|
... # как раньше
|
||||||
|
```
|
||||||
|
|
||||||
|
### web/templates/selected.html
|
||||||
|
|
||||||
|
В форме reclassify (кнопка «🤖 Обработать моделью») добавить select:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<form method="post" action="/posts/{{ selected.id }}/reclassify" class="d-inline-flex align-items-center gap-1">
|
||||||
|
{{ hid }}
|
||||||
|
<select name="manual_direction" class="form-select form-select-sm w-auto">
|
||||||
|
<option value="">— модель —</option>
|
||||||
|
{% for d in directions %}
|
||||||
|
<option value="{{ d }}">{{ d }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-info" type="submit">🤖 Обработать моделью</button>
|
||||||
|
</form>
|
||||||
|
```
|
||||||
|
|
||||||
|
Поведение:
|
||||||
|
- «— модель —»: прежнее поведение (LLM).
|
||||||
|
- выбор направления: установится принудительно, без LLM.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# manual-direction
|
||||||
|
|
||||||
|
Возможность вручную указывать/менять направление поста при анализе.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Пост без классификации отображается со значком «?» (published.html: `direction or '?'`).
|
||||||
|
Кнопка «Обработать моделью» перезапускает LLM, но если модель не выдаёт каноническое
|
||||||
|
направление — пост так и остаётся без него. Пользователю нужен способ явно задать
|
||||||
|
направление вручную (linux/tech/politics/games/electronics/llm) — например, когда
|
||||||
|
классификатор ошибся или не смог определить тему.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- **web/app.py** `post_reclassify`/`impl_reclassify`: новый параметр `manual_direction`
|
||||||
|
(select в форме). Если задан и входит в DIRECTIONS_CANON — направление пишется в БД
|
||||||
|
напрямую (без LLM), `classified=1`, relevance/interest — как при словарном попадании.
|
||||||
|
- **web/templates/selected.html**: перед кнопкой «🤖 Обработать моделью» — select
|
||||||
|
направлений («— модель —» по умолчанию = оставить как есть). Если выбрать конкретное
|
||||||
|
направление — задастся принудительно.
|
||||||
|
- **web/templates/candidates.html**: аналогичный select у формы reclassify? В кандидатах
|
||||||
|
reclassify нет (только «Отобрать»/«Отклонить») — добавление не требуется, но для
|
||||||
|
полноты добавим select кнопке reclassify в selected.html (единственное место с reclassify).
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Пользователь может вручную исправить/добавить направление, не дожидаясь LLM.
|
||||||
|
- Даёт пост классификацию даже если модель «не знает» — важно для публикации
|
||||||
|
(published.html без «?»).
|
||||||
+7
-2
@@ -25,7 +25,9 @@ def make_card(post: dict, comment: str | None = None) -> dict:
|
|||||||
это был «агрегаторный» вид карточки.
|
это был «агрегаторный» вид карточки.
|
||||||
"""
|
"""
|
||||||
comment = (comment or "").strip()
|
comment = (comment or "").strip()
|
||||||
body = (post.get("text") or "").strip()
|
# тело: пересказ (rewritten_text) приоритетнее исходного текста
|
||||||
|
body = (post.get("rewritten_text") or post.get("text") or "").strip()
|
||||||
|
rewritten = bool((post.get("rewritten_text") or "").strip())
|
||||||
|
|
||||||
# ссылка на оригинал / атрибуция своего контента
|
# ссылка на оригинал / атрибуция своего контента
|
||||||
is_own = int(post.get("is_own") or 0) == 1
|
is_own = int(post.get("is_own") or 0) == 1
|
||||||
@@ -34,7 +36,10 @@ def make_card(post: dict, comment: str | None = None) -> dict:
|
|||||||
orig = f"https://t.me/dedinit/{post['tg_post_id']}"
|
orig = f"https://t.me/dedinit/{post['tg_post_id']}"
|
||||||
elif post.get("url"):
|
elif post.get("url"):
|
||||||
orig = post.get("url")
|
orig = post.get("url")
|
||||||
if is_own:
|
if rewritten:
|
||||||
|
# пересказ — публикация от имени автора канала (не пересылка)
|
||||||
|
tail = "✍️ Дед в АйТи (@dedinit)" + (f" · Источник: {orig}" if orig else "")
|
||||||
|
elif is_own:
|
||||||
tail = "✍️ Дед в АйТи (@dedinit)" + (f" · {orig}" if orig else "")
|
tail = "✍️ Дед в АйТи (@dedinit)" + (f" · {orig}" if orig else "")
|
||||||
elif orig:
|
elif orig:
|
||||||
tail = f"🔗 Оригинал: {orig}"
|
tail = f"🔗 Оригинал: {orig}"
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# VESTI — извлечение потенциальных источников из текста поста (без LLM, regex).
|
||||||
|
import re
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
_MAX_PER_POST = 5
|
||||||
|
|
||||||
|
# t.me/handle, t.me/s/handle, telegram.me/handle
|
||||||
|
_TG_RE = re.compile(
|
||||||
|
r"(?:https?://)?(?:www\.)?(?:t\.me|telegram\.me)/(?:s/)?([a-zA-Z][a-zA-Z0-9_]{3,31})"
|
||||||
|
)
|
||||||
|
# @handle (не email, не на конце слова)
|
||||||
|
_AT_RE = re.compile(r"(?<!\w)@([a-zA-Z][a-zA-Z0-9_]{3,31})\b")
|
||||||
|
# внешние http(s)-ссылки
|
||||||
|
_URL_RE = re.compile(r"https?://([^\s<>\"']+)", re.I)
|
||||||
|
|
||||||
|
_TG_DOMAINS = ("t.me", "telegram.me")
|
||||||
|
|
||||||
|
|
||||||
|
def _domain_of(url: str) -> str:
|
||||||
|
return (urlparse(url if "://" in url else "https://" + url).netloc or "").lower()
|
||||||
|
|
||||||
|
|
||||||
|
def extract_sources_from_text(text: str, exclude_tg_url: str | None = None) -> list[dict]:
|
||||||
|
"""Ищет TG-каналы и внешние сайты в тексте поста.
|
||||||
|
|
||||||
|
exclude_tg_url — url самого поста (t.me/channel/postid): ссылку на него
|
||||||
|
отбрасываем (это адрес поста, а не источник).
|
||||||
|
|
||||||
|
Возвращает список dict: {"kind": "telegram"|"site", "address": str,
|
||||||
|
"slug_hint": str|None, "domain": str|None}
|
||||||
|
максимум _MAX_PER_POST элементов.
|
||||||
|
"""
|
||||||
|
out: list[dict] = []
|
||||||
|
seen: set[tuple] = set()
|
||||||
|
|
||||||
|
def add(kind: str, address: str, slug_hint: str | None, domain: str | None):
|
||||||
|
key = (kind, slug_hint or domain or address)
|
||||||
|
if key in seen:
|
||||||
|
return
|
||||||
|
seen.add(key)
|
||||||
|
out.append({"kind": kind, "address": address, "slug_hint": slug_hint, "domain": domain})
|
||||||
|
|
||||||
|
text = (text or "")[:4000]
|
||||||
|
|
||||||
|
for m in _TG_RE.finditer(text):
|
||||||
|
ch = m.group(1)
|
||||||
|
if exclude_tg_url and f"/{ch}/" in exclude_tg_url:
|
||||||
|
continue # ссылка на сам пост
|
||||||
|
add("telegram", f"https://t.me/{ch}", ch, None)
|
||||||
|
if len(out) >= _MAX_PER_POST:
|
||||||
|
break
|
||||||
|
|
||||||
|
for m in _AT_RE.finditer(text):
|
||||||
|
add("telegram", f"https://t.me/{m.group(1)}", m.group(1), None)
|
||||||
|
if len(out) >= _MAX_PER_POST:
|
||||||
|
break
|
||||||
|
|
||||||
|
for m in _URL_RE.finditer(text):
|
||||||
|
u = m.group(1).rstrip(".,;:!?)]}\"'")
|
||||||
|
dom = _domain_of(u)
|
||||||
|
if not dom:
|
||||||
|
continue
|
||||||
|
if dom in _TG_DOMAINS or dom.endswith(".t.me"):
|
||||||
|
continue # уже обработано выше
|
||||||
|
add("site", u, None, dom)
|
||||||
|
if len(out) >= _MAX_PER_POST:
|
||||||
|
break
|
||||||
|
|
||||||
|
return out[:_MAX_PER_POST]
|
||||||
+110
-56
@@ -1,8 +1,27 @@
|
|||||||
# VESTI sources — реестр источников (git). Формат продолжает /opt/news.
|
- slug: ai_machinelearning_big_data
|
||||||
# Прототип: направление linux, язык ru.
|
name: Machinelearning
|
||||||
# Каналы из списка пользователя (2026-09-08).
|
url: https://t.me/ai_machinelearning_big_data
|
||||||
|
channel: ai_machinelearning_big_data
|
||||||
|
crawler: telegram
|
||||||
|
feed_url: null
|
||||||
|
direction: llm
|
||||||
|
lang: ru
|
||||||
|
priority: P0
|
||||||
|
enabled: true
|
||||||
|
own: false
|
||||||
|
- slug: dedinit
|
||||||
|
name: Дед в АйТи
|
||||||
|
url: https://t.me/dedinit
|
||||||
|
channel: dedinit
|
||||||
|
crawler: telegram
|
||||||
|
direction: null
|
||||||
|
lang: ru
|
||||||
|
priority: P0
|
||||||
|
enabled: true
|
||||||
|
own: true
|
||||||
|
notes: собственный канал пользователя — источник своего контента (own-content-hub)
|
||||||
- slug: linuxklub
|
- slug: linuxklub
|
||||||
name: "Linux Klub"
|
name: Linux Klub
|
||||||
url: https://t.me/linuxklub
|
url: https://t.me/linuxklub
|
||||||
channel: linuxklub
|
channel: linuxklub
|
||||||
crawler: telegram
|
crawler: telegram
|
||||||
@@ -10,10 +29,9 @@
|
|||||||
lang: ru
|
lang: ru
|
||||||
priority: P0
|
priority: P0
|
||||||
enabled: true
|
enabled: true
|
||||||
notes: "линукс-канал из списка пользователя"
|
notes: линукс-канал из списка пользователя
|
||||||
|
|
||||||
- slug: linuxos_tg
|
- slug: linuxos_tg
|
||||||
name: "Linux OS"
|
name: Linux OS
|
||||||
url: https://t.me/linuxos_tg
|
url: https://t.me/linuxos_tg
|
||||||
channel: linuxos_tg
|
channel: linuxos_tg
|
||||||
crawler: telegram
|
crawler: telegram
|
||||||
@@ -21,9 +39,31 @@
|
|||||||
lang: ru
|
lang: ru
|
||||||
priority: P0
|
priority: P0
|
||||||
enabled: true
|
enabled: true
|
||||||
|
- slug: lwn
|
||||||
|
name: LWN.net
|
||||||
|
url: https://lwn.net/
|
||||||
|
feed_url: https://lwn.net/headlines/rss
|
||||||
|
channel: null
|
||||||
|
crawler: rss
|
||||||
|
direction: linux
|
||||||
|
lang: en
|
||||||
|
priority: P0
|
||||||
|
enabled: true
|
||||||
|
notes: первый реальный RSS-источник для проверки rss_crawler
|
||||||
|
own: false
|
||||||
|
- slug: bleepingcomputer
|
||||||
|
name: omarchy
|
||||||
|
url: https://www.bleepingcomputer.com
|
||||||
|
channel: null
|
||||||
|
crawler: rss
|
||||||
|
feed_url: https://www.bleepingcomputer.com/feed/
|
||||||
|
direction: null
|
||||||
|
lang: ru
|
||||||
|
priority: P1
|
||||||
|
enabled: true
|
||||||
|
own: false
|
||||||
- slug: dotfiles_linux
|
- slug: dotfiles_linux
|
||||||
name: "Dotfiles Linux"
|
name: Dotfiles Linux
|
||||||
url: https://t.me/dotfiles_linux
|
url: https://t.me/dotfiles_linux
|
||||||
channel: dotfiles_linux
|
channel: dotfiles_linux
|
||||||
crawler: telegram
|
crawler: telegram
|
||||||
@@ -31,39 +71,8 @@
|
|||||||
lang: ru
|
lang: ru
|
||||||
priority: P1
|
priority: P1
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
- slug: linux_education
|
|
||||||
name: "Linux Education"
|
|
||||||
url: https://t.me/linux_education
|
|
||||||
channel: linux_education
|
|
||||||
crawler: telegram
|
|
||||||
direction: linux
|
|
||||||
lang: ru
|
|
||||||
priority: P1
|
|
||||||
enabled: true
|
|
||||||
|
|
||||||
- slug: linuxmastery
|
|
||||||
name: "Linux Mastery"
|
|
||||||
url: https://t.me/LinuxMastery
|
|
||||||
channel: LinuxMastery
|
|
||||||
crawler: telegram
|
|
||||||
direction: linux
|
|
||||||
lang: ru
|
|
||||||
priority: P1
|
|
||||||
enabled: true
|
|
||||||
|
|
||||||
- slug: linuxcamp_tg
|
|
||||||
name: "Linux Camp"
|
|
||||||
url: https://t.me/linuxcamp_tg
|
|
||||||
channel: linuxcamp_tg
|
|
||||||
crawler: telegram
|
|
||||||
direction: linux
|
|
||||||
lang: ru
|
|
||||||
priority: P1
|
|
||||||
enabled: true
|
|
||||||
|
|
||||||
- slug: gitgate
|
- slug: gitgate
|
||||||
name: "GitGate"
|
name: GitGate
|
||||||
url: https://t.me/gitgate
|
url: https://t.me/gitgate
|
||||||
channel: gitgate
|
channel: gitgate
|
||||||
crawler: telegram
|
crawler: telegram
|
||||||
@@ -71,10 +80,9 @@
|
|||||||
lang: ru
|
lang: ru
|
||||||
priority: P1
|
priority: P1
|
||||||
enabled: true
|
enabled: true
|
||||||
notes: "git/devops — смежный с linux"
|
notes: git/devops — смежный с linux
|
||||||
|
|
||||||
- slug: krxnotes
|
- slug: krxnotes
|
||||||
name: "KRX Notes"
|
name: KRX Notes
|
||||||
url: https://t.me/krxnotes
|
url: https://t.me/krxnotes
|
||||||
channel: krxnotes
|
channel: krxnotes
|
||||||
crawler: telegram
|
crawler: telegram
|
||||||
@@ -82,18 +90,64 @@
|
|||||||
lang: ru
|
lang: ru
|
||||||
priority: P1
|
priority: P1
|
||||||
enabled: true
|
enabled: true
|
||||||
|
- slug: linux_education
|
||||||
# --- Собственный контент (own: true) — канал пользователя ---------------
|
name: Linux Education
|
||||||
# Посты этого канала — СВОЙ контент пользователя (is_own=1), медиа скачивается,
|
url: https://t.me/linux_education
|
||||||
# кандидат получает приоритет (relevance=critical) и fan-out по всем направлениям при approve.
|
channel: linux_education
|
||||||
- slug: dedinit
|
|
||||||
name: "Дед в АйТи"
|
|
||||||
url: https://t.me/dedinit
|
|
||||||
channel: dedinit
|
|
||||||
crawler: telegram
|
crawler: telegram
|
||||||
direction: null # свой канал — направление не фиксировано (классификатор решает)
|
direction: linux
|
||||||
lang: ru
|
lang: ru
|
||||||
priority: P0
|
priority: P1
|
||||||
enabled: true
|
enabled: true
|
||||||
own: true
|
- slug: linuxcamp_tg
|
||||||
notes: "собственный канал пользователя — источник своего контента (own-content-hub)"
|
name: Linux Camp
|
||||||
|
url: https://t.me/linuxcamp_tg
|
||||||
|
channel: linuxcamp_tg
|
||||||
|
crawler: telegram
|
||||||
|
direction: linux
|
||||||
|
lang: ru
|
||||||
|
priority: P1
|
||||||
|
enabled: true
|
||||||
|
- slug: linuxmastery
|
||||||
|
name: Linux Mastery
|
||||||
|
url: https://t.me/LinuxMastery
|
||||||
|
channel: LinuxMastery
|
||||||
|
crawler: telegram
|
||||||
|
direction: linux
|
||||||
|
lang: ru
|
||||||
|
priority: P1
|
||||||
|
enabled: true
|
||||||
|
- slug: mknewsru
|
||||||
|
name: Мой Компьютер
|
||||||
|
url: https://t.me/mknewsru
|
||||||
|
channel: mknewsru
|
||||||
|
crawler: telegram
|
||||||
|
feed_url: null
|
||||||
|
direction: tech
|
||||||
|
lang: ru
|
||||||
|
priority: P1
|
||||||
|
enabled: true
|
||||||
|
own: false
|
||||||
|
- slug: omarchy
|
||||||
|
name: omarchy
|
||||||
|
url: https://omarchy.org/
|
||||||
|
channel: null
|
||||||
|
crawler: rss
|
||||||
|
feed_url: https://omarchy.org/news/rss.xml
|
||||||
|
direction: null
|
||||||
|
lang: ru
|
||||||
|
priority: P1
|
||||||
|
enabled: false
|
||||||
|
own: false
|
||||||
|
|
||||||
|
# Пример (включить, раскомментировав и поправив direction/приоритет):
|
||||||
|
# - slug: opennet
|
||||||
|
# name: "OpenNET"
|
||||||
|
# url: https://www.opennet.ru/
|
||||||
|
# feed_url: https://www.opennet.ru/opennews/opennews_all.rss
|
||||||
|
# channel: null
|
||||||
|
# crawler: rss
|
||||||
|
# direction: linux
|
||||||
|
# lang: ru
|
||||||
|
# priority: P1
|
||||||
|
# enabled: true
|
||||||
|
|||||||
+89
-5
@@ -110,6 +110,24 @@ tpl.filters["safe_html"] = safe_html_filter
|
|||||||
tpl.filters["dt"] = dt_filter
|
tpl.filters["dt"] = dt_filter
|
||||||
|
|
||||||
|
|
||||||
|
def _archive_href(year: str = "", month: str = "", nodate: bool = False, page: int = 1) -> str:
|
||||||
|
"""URL ссылки архива: '/published' без параметров или с query (страница 1 не пишется)."""
|
||||||
|
parts = []
|
||||||
|
if year:
|
||||||
|
parts.append(f"year={year}")
|
||||||
|
if month:
|
||||||
|
parts.append(f"month={month}")
|
||||||
|
if nodate:
|
||||||
|
parts.append("nodate=1")
|
||||||
|
if page and page > 1:
|
||||||
|
parts.append(f"page={page}")
|
||||||
|
qs = "&".join(parts)
|
||||||
|
return f"/published?{qs}" if qs else "/published"
|
||||||
|
|
||||||
|
|
||||||
|
tpl.globals["_archive_href"] = _archive_href
|
||||||
|
|
||||||
|
|
||||||
def _db() -> sqlite3.Connection:
|
def _db() -> sqlite3.Connection:
|
||||||
conn = sqlite3.connect(DB_PATH, timeout=10)
|
conn = sqlite3.connect(DB_PATH, timeout=10)
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
@@ -136,6 +154,41 @@ def _migrate():
|
|||||||
_migrate()
|
_migrate()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Архив опубликованных: дерево навигации по годам/месяцам ---
|
||||||
|
MONTH_NAMES_RU = ("", "Янв", "Фев", "Мар", "Апр", "Май", "Июн",
|
||||||
|
"Июл", "Авг", "Сен", "Окт", "Ноя", "Дек")
|
||||||
|
ARCHIVE_PAGE_SIZE = 10
|
||||||
|
|
||||||
|
|
||||||
|
def _archive_tree(conn: sqlite3.Connection) -> dict:
|
||||||
|
"""Дерево навигации архива: {years: [{year, months:[{m, label, n}], n}], no_date: n}.
|
||||||
|
Строится только из существующих периодов (GROUP BY); пустые месяцы отсутствуют —
|
||||||
|
как в Hugo-архивах, где дерево порождается постами, а не календарём."""
|
||||||
|
rows = conn.execute(
|
||||||
|
"""SELECT strftime('%Y', published_at) y, strftime('%m', published_at) m,
|
||||||
|
COUNT(*) n
|
||||||
|
FROM posts WHERE status='published'
|
||||||
|
AND published_at IS NOT NULL AND published_at != ''
|
||||||
|
GROUP BY y, m ORDER BY y DESC, m DESC"""
|
||||||
|
).fetchall()
|
||||||
|
years, cur = [], None
|
||||||
|
for r in rows:
|
||||||
|
if cur is None or cur["year"] != r["y"]:
|
||||||
|
cur = {"year": r["y"], "months": [], "n": 0}
|
||||||
|
years.append(cur)
|
||||||
|
cur["months"].append({
|
||||||
|
"m": r["m"],
|
||||||
|
"label": f"{MONTH_NAMES_RU[int(r['m'])]}",
|
||||||
|
"n": r["n"],
|
||||||
|
})
|
||||||
|
cur["n"] += r["n"]
|
||||||
|
no_date = conn.execute(
|
||||||
|
"""SELECT COUNT(*) FROM posts
|
||||||
|
WHERE status='published' AND (published_at IS NULL OR published_at='')"""
|
||||||
|
).fetchone()[0]
|
||||||
|
return {"years": years, "no_date": no_date}
|
||||||
|
|
||||||
|
|
||||||
def _session_ok(request: Request) -> bool:
|
def _session_ok(request: Request) -> bool:
|
||||||
return request.cookies.get(SESSION_COOKIE) == os.getenv("VESTI_WEB_SESSION", "")
|
return request.cookies.get(SESSION_COOKIE) == os.getenv("VESTI_WEB_SESSION", "")
|
||||||
|
|
||||||
@@ -733,19 +786,50 @@ def post_translate(post_id: int, request: Request,
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/published", response_class=HTMLResponse)
|
@app.get("/published", response_class=HTMLResponse)
|
||||||
def published(request: Request):
|
def published(request: Request, year: str = "", month: str = "", nodate: str = "", page: int = 1):
|
||||||
|
"""Архив опубликованных: дерево навигации по годам/месяцам (правая колонка),
|
||||||
|
посты за выбранный период, пагинация по 10."""
|
||||||
_require_auth(request)
|
_require_auth(request)
|
||||||
|
page = max(1, int(page or 1))
|
||||||
conn = _db()
|
conn = _db()
|
||||||
|
tree = _archive_tree(conn)
|
||||||
|
|
||||||
|
filters, params = [], []
|
||||||
|
filters.append("p.status='published'")
|
||||||
|
if nodate:
|
||||||
|
filters.append("(p.published_at IS NULL OR p.published_at='')")
|
||||||
|
else:
|
||||||
|
filters.append("(p.published_at IS NOT NULL AND p.published_at != '')")
|
||||||
|
if year:
|
||||||
|
filters.append("strftime('%Y', p.published_at) = ?")
|
||||||
|
params.append(year)
|
||||||
|
if month:
|
||||||
|
filters.append("strftime('%m', p.published_at) = ?")
|
||||||
|
params.append(month)
|
||||||
|
where = " AND ".join(filters)
|
||||||
|
|
||||||
|
total = conn.execute(f"SELECT COUNT(*) FROM posts p WHERE {where}", params).fetchone()[0]
|
||||||
|
pages = max(1, (total + ARCHIVE_PAGE_SIZE - 1) // ARCHIVE_PAGE_SIZE)
|
||||||
|
page = min(page, pages)
|
||||||
|
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""SELECT p.*, s.name source_name,
|
f"""SELECT p.*, s.name source_name,
|
||||||
pub.tg_message_id, pub.views pub_views, pub.bundle_path, pub.distributed_dirs
|
pub.tg_message_id, pub.views pub_views, pub.bundle_path, pub.distributed_dirs
|
||||||
FROM posts p
|
FROM posts p
|
||||||
LEFT JOIN sources s ON s.id=p.source_id
|
LEFT JOIN sources s ON s.id=p.source_id
|
||||||
LEFT JOIN published pub ON pub.post_id=p.id
|
LEFT JOIN (SELECT post_id, MAX(id) mid FROM published GROUP BY post_id) lastpub
|
||||||
WHERE p.status='published' ORDER BY p.published_at DESC LIMIT 100"""
|
ON lastpub.post_id=p.id
|
||||||
|
LEFT JOIN published pub ON pub.id=lastpub.mid
|
||||||
|
WHERE {where}
|
||||||
|
ORDER BY p.published_at DESC LIMIT ? OFFSET ?""",
|
||||||
|
params + [ARCHIVE_PAGE_SIZE, (page - 1) * ARCHIVE_PAGE_SIZE]
|
||||||
).fetchall()
|
).fetchall()
|
||||||
conn.close()
|
conn.close()
|
||||||
html = tpl.get_template("published.html").render(posts=rows)
|
|
||||||
|
html = tpl.get_template("published.html").render(
|
||||||
|
posts=rows, tree=tree, year=year, month=month, nodate=bool(nodate),
|
||||||
|
page=page, pages=pages, total=total,
|
||||||
|
)
|
||||||
return HTMLResponse(html)
|
return HTMLResponse(html)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Краулеры{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h4 class="mb-3">🤖 Краулеры</h4>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card text-center">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title text-muted">Очередь (pending)</h6>
|
||||||
|
<h3 class="mb-0">{{ pending }}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card text-center">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title text-muted">В обработке (processing)</h6>
|
||||||
|
<h3 class="mb-0">{{ processing }}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card text-center">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title text-muted">Обработано сегодня</h6>
|
||||||
|
<h3 class="mb-0">{{ done_today }}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card text-center">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title text-muted">Источники</h6>
|
||||||
|
<h3 class="mb-0">{{ alive }}<small class="text-muted">/{{ total_sources}}</small>
|
||||||
|
{% if dead %} <span class="badge bg-danger">{{ dead }} dead</span>{% endif %}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2 mb-3">
|
||||||
|
<form method="post" action="/crawlers/run" class="d-inline">
|
||||||
|
<label class="me-1"><small>воркеры</small>
|
||||||
|
<input type="number" name="workers" value="8" min="1" max="32" class="form-control form-control-sm d-inline-block" style="width:70px">
|
||||||
|
</label>
|
||||||
|
<label class="me-2"><small>лимит</small>
|
||||||
|
<input type="number" name="limit" value="200" min="1" max="1000" class="form-control form-control-sm d-inline-block" style="width:80px">
|
||||||
|
</label>
|
||||||
|
<button class="btn btn-success btn-sm">▶ Запустить воркер</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/crawlers/reset" class="d-inline">
|
||||||
|
<button class="btn btn-warning btn-sm">↻ Сбросить dead</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if request.query_params.get('run') == 'started' %}
|
||||||
|
<div class="alert alert-success py-2">Воркер запущен ({{ request.query_params.get('workers') }} потоков, лимит {{ request.query_params.get('limit') }}). Следите за processing → done.</div>
|
||||||
|
{% elif request.query_params.get('run') == 'error' %}
|
||||||
|
<div class="alert alert-danger py-2">Ошибка запуска: {{ request.query_params.get('msg') }}</div>
|
||||||
|
{% elif request.query_params.get('reset') %}
|
||||||
|
<div class="alert alert-info py-2">Сброшено {{ request.query_params.get('reset') }} source(s) в alive.</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<h5 class="mt-4">Источники</h5>
|
||||||
|
<table class="table table-sm table-striped">
|
||||||
|
<thead><tr><th>Источник</th><th>Тип</th><th>Статус</th><th>Последний сбор</th><th>Ошибки</th><th>Приоритет</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for s in sources %}
|
||||||
|
<tr>
|
||||||
|
<td><b>{{ s.name }}</b><br><small class="text-muted">{{ s.slug }}</small></td>
|
||||||
|
<td><span class="badge bg-secondary">{{ s.crawler }}</span></td>
|
||||||
|
<td>
|
||||||
|
{% if s.status == 'dead' %}<span class="badge bg-danger">dead</span>
|
||||||
|
{% elif s.enabled %}<span class="badge bg-success">alive</span>
|
||||||
|
{% else %}<span class="badge bg-secondary">off</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ s.last_fetch | dt if s.last_fetch else '—' }}</td>
|
||||||
|
<td>{% if s.error_count %}<span class="text-danger">{{ s.error_count }}</span>{% else %}—{% endif %}
|
||||||
|
{% if s.last_error %}<br><small class="text-muted">{{ s.last_error }}</small>{% endif %}</td>
|
||||||
|
<td>{{ s.priority }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h5 class="mt-4">Последние запуски</h5>
|
||||||
|
<table class="table table-sm table-striped">
|
||||||
|
<thead><tr><th>Время</th><th>Источник</th><th>Статус</th><th>Постов</th><th>Длительность</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for r in runs %}
|
||||||
|
<tr><td>{{ r.started_at | dt }}</td><td>{{ r.source_name or r.source_id }}</td>
|
||||||
|
<td>{{ r.status }}</td><td>{{ r.posts_new }}/{{ r.posts_fetched }}</td>
|
||||||
|
<td>{% if r.duration_ms %}{{ "%.1f"|format(r.duration_ms/1000) }}s{% else %}—{% endif %}</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,8 +1,26 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Опубликовано{% endblock %}
|
{% block title %}Архив — Опубликовано{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h4 class="mb-3">Опубликованные посты</h4>
|
<div class="row">
|
||||||
{% for p in posts %}
|
<!-- Левая колонка: список постов за выбранный период -->
|
||||||
|
<div class="col-lg-9">
|
||||||
|
<h4 class="mb-3">
|
||||||
|
Опубликованные посты
|
||||||
|
{% set filter_label = '' %}
|
||||||
|
{% if nodate %}
|
||||||
|
{% set filter_label = 'Без даты' %}
|
||||||
|
{% elif year and month %}
|
||||||
|
{% set filter_label = year ~ '-' ~ month %}
|
||||||
|
{% elif year %}
|
||||||
|
{% set filter_label = year %}
|
||||||
|
{% endif %}
|
||||||
|
{% if filter_label %}
|
||||||
|
<span class="badge bg-secondary ms-2">{{ filter_label }}</span>
|
||||||
|
{% endif %}
|
||||||
|
<small class="text-muted fs-6 fw-normal">({{ total }})</small>
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
{% for p in posts %}
|
||||||
<div class="card mb-3">
|
<div class="card mb-3">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="d-flex justify-content-between">
|
<div class="d-flex justify-content-between">
|
||||||
@@ -37,7 +55,83 @@
|
|||||||
{% if p.tg_message_id %}<span class="badge bg-light text-dark ms-2">msg #{{ p.tg_message_id }}</span>{% endif %}
|
{% if p.tg_message_id %}<span class="badge bg-light text-dark ms-2">msg #{{ p.tg_message_id }}</span>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="alert alert-info">Пока ничего не опубликовано</div>
|
<div class="alert alert-info">Нет постов за этот период</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if pages > 1 %}
|
||||||
|
<nav aria-label="Пагинация архива">
|
||||||
|
<ul class="pagination justify-content-center">
|
||||||
|
<li class="page-item {% if page <= 1 %}disabled{% endif %}">
|
||||||
|
<a class="page-link" href="{{ _archive_href(year, month, nodate, page-1) }}" aria-label="Назад">«</a>
|
||||||
|
</li>
|
||||||
|
{% set from = [1, page - 3] | max %}
|
||||||
|
{% set to = [pages, page + 3] | min %}
|
||||||
|
{% if from > 1 %}
|
||||||
|
<li class="page-item"><a class="page-link" href="{{ _archive_href(year, month, nodate, 1) }}">1</a></li>
|
||||||
|
{% if from > 2 %}<li class="page-item disabled"><span class="page-link">…</span></li>{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% for n in range(from, to + 1) %}
|
||||||
|
<li class="page-item {% if n == page %}active{% endif %}">
|
||||||
|
<a class="page-link" href="{{ _archive_href(year, month, nodate, n) }}">{{ n }}</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
{% if to < pages %}
|
||||||
|
{% if to < pages - 1 %}<li class="page-item disabled"><span class="page-link">…</span></li>{% endif %}
|
||||||
|
<li class="page-item"><a class="page-link" href="{{ _archive_href(year, month, nodate, pages) }}">{{ pages }}</a></li>
|
||||||
|
{% endif %}
|
||||||
|
<li class="page-item {% if page >= pages %}disabled{% endif %}">
|
||||||
|
<a class="page-link" href="{{ _archive_href(year, month, nodate, page+1) }}" aria-label="Вперёд">»</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Правая колонка: дерево навигации по годам/месяцам -->
|
||||||
|
<div class="col-lg-3">
|
||||||
|
<!-- На мобильных — сворачивается под кнопку -->
|
||||||
|
<button class="btn btn-outline-secondary d-lg-none w-100 mb-2" type="button"
|
||||||
|
data-bs-toggle="collapse" data-bs-target="#archive-tree" aria-expanded="false" aria-controls="archive-tree">
|
||||||
|
🗂 Архив
|
||||||
|
</button>
|
||||||
|
<div id="archive-tree" class="collapse d-lg-block position-sticky" style="top: 5rem;">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body py-3">
|
||||||
|
<h6 class="card-title mb-2">🗂 Архив</h6>
|
||||||
|
<div class="list-group list-group-flush">
|
||||||
|
<a href="/published"
|
||||||
|
class="list-group-item list-group-item-action d-flex justify-content-between align-items-center {% if not year and not month and not nodate %}active{% endif %}">
|
||||||
|
Все посты
|
||||||
|
<span class="badge rounded-pill {% if not year and not month and not nodate %}bg-light text-dark{% else %}bg-secondary{% endif %}">{{ tree.years | sum(attribute='n') + tree.no_date }}</span>
|
||||||
|
</a>
|
||||||
|
{% for y in tree.years %}
|
||||||
|
<a href="/published?year={{ y.year }}"
|
||||||
|
class="list-group-item list-group-item-action d-flex justify-content-between align-items-center {% if year == y.year and not month and not nodate %}active{% endif %}">
|
||||||
|
{{ y.year }}
|
||||||
|
<span class="badge rounded-pill {% if year == y.year and not month and not nodate %}bg-light text-dark{% else %}bg-secondary{% endif %}">{{ y.n }}</span>
|
||||||
|
</a>
|
||||||
|
{% if year == y.year %}
|
||||||
|
{% for m in y.months %}
|
||||||
|
<a href="/published?year={{ y.year }}&month={{ m.m }}"
|
||||||
|
class="list-group-item list-group-item-action ps-4 d-flex justify-content-between align-items-center {% if month == m.m and not nodate %}active{% endif %}">
|
||||||
|
{{ m.label }}
|
||||||
|
<span class="badge rounded-pill {% if month == m.m and not nodate %}bg-light text-dark{% else %}bg-secondary{% endif %}">{{ m.n }}</span>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% if tree.no_date > 0 %}
|
||||||
|
<a href="/published?nodate=1"
|
||||||
|
class="list-group-item list-group-item-action d-flex justify-content-between align-items-center {% if nodate %}active{% endif %}">
|
||||||
|
Без даты
|
||||||
|
<span class="badge rounded-pill {% if nodate %}bg-light text-dark{% else %}bg-secondary{% endif %}">{{ tree.no_date }}</span>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user