Initial import: vesti.nixg.ru — новостной апрув-проект (web, crawler, classifier, publisher, openspec)

This commit is contained in:
kpa39l
2026-09-13 15:58:32 +00:00
commit c3f59f7b7a
113 changed files with 7065 additions and 0 deletions
+119
View File
@@ -0,0 +1,119 @@
# publisher-service — изолированный FastAPI-микросервис публикации в Telegram.
# POST /api/v1/publish — публикация карточки в канал(ы). GET /healthz — статус.
# Единственная точка, знающая токен бота, прокси и каналы.
import os
from pathlib import Path
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
from .channels import resolve_channels
from .config import settings
from . import telegram
app = FastAPI(title="VESTI Publisher Service", version="0.1.0")
# путь к .env для test/curl (вне контейнера) — читаем через config (load_dotenv уже там)
BASE = Path(__file__).resolve().parent.parent.parent
class Card(BaseModel):
text: str = Field(..., min_length=1, max_length=4096)
media: str | None = None # путь к локальному файлу (если есть)
direction: str = "linux"
lang: str = "ru"
class PublishRequest(BaseModel):
card: Card
channels: list[str] | None = None # MAY переопределять конфиг
dry_run: bool = False # тестовый режим: не уходит в Telegram
class ChannelResult(BaseModel):
message_id: int = 0
media_message_id: int | None = None
views: int = 0
error: str | None = None
class PublishResponse(BaseModel):
ok: bool
results: dict[str, ChannelResult]
dry_run: bool = False
@app.get("/healthz")
def healthz():
"""Статус сервиса: бот, прокси, каналы, токен."""
me_name = ""
try:
me = telegram.get_me()
me_name = me.get("username", "")
except Exception:
me_name = ""
return {
"status": "ok",
"bot": me_name or ("не проверен" if settings.bot_token else "нет токена"),
"token_set": bool(settings.bot_token),
"proxy": settings.tg_proxy,
"channels": settings.channels,
}
@app.post("/api/v1/publish", response_model=PublishResponse)
def publish(req: PublishRequest):
"""Публикует «богатый репост» в каналы. Возвращает message_id по каждому каналу.
Порядок: медиа первым (sendPhoto/sendVideo) → текст вторым (sendMessage с
link_preview_options disabled, чтобы не было предпросмотра ссылки).
"""
channels = resolve_channels(req.channels)
if not channels:
raise HTTPException(status_code=400, detail="Нет каналов для публикации")
# dry_run — тестовый режим: НЕ уходит в Telegram, возвращает эмуляцию результата
if req.dry_run:
results: dict[str, ChannelResult] = {
ch: ChannelResult() for ch in channels
}
return PublishResponse(ok=True, results=results, dry_run=True)
results: dict[str, ChannelResult] = {}
all_ok = True
for ch in channels:
res = ChannelResult()
try:
if req.card.media and Path(req.card.media).exists():
# медиа первым (caption короткий: первые 1000 символов, т.к. лимит 1024)
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
else:
# без медиа: текст вторым; предпросмотр ссылки отключён (богатый репост)
msg = telegram.send_message(ch, req.card.text)
res.message_id = msg
res.views = telegram.get_views(ch, res.message_id)
except telegram.TelegramError as e:
res.error = e.message
all_ok = False
results[ch] = res
if not all_ok:
# одна ошибка не роняет остальные; статус 502 если все упали
if all(r.error for r in results.values()):
raise HTTPException(status_code=502, detail={ch: r.error for ch, r in results.items()})
return PublishResponse(ok=all_ok, results=results)
@app.get("/api/v1/views/{channel}/{message_id}")
def views(channel: str, message_id: int):
"""Views по каналу/сообщению (метрики)."""
return {"channel": channel, "message_id": message_id, "views": telegram.get_views(channel, message_id)}
if __name__ == "__main__":
import uvicorn
uvicorn.run("services.publisher.app.main:app", host="127.0.0.1", port=settings.port)