mirror of
https://gitverse.ru/kpa39l/vesti.git
synced 2026-09-29 09:55:03 +00:00
Initial import: vesti.nixg.ru — новостной апрув-проект (web, crawler, classifier, publisher, openspec)
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
# Публикация карточек в Telegram через Bot API.
|
||||
# Бот: @dedinit_vesti_<direction>_<lang>_bot (шаблон из конфига .env).
|
||||
# Режим подтверждения: веб вызывает publish() только после явного клика.
|
||||
# Метрики: getChatMemberCount / getMessage (views) для опубликованных.
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
BOT_TOKEN = os.getenv("VESTI_BOT_TOKEN", "")
|
||||
API = "https://api.telegram.org"
|
||||
TIMEOUT = 20.0
|
||||
|
||||
|
||||
def _bot_url(method: str) -> str:
|
||||
if not BOT_TOKEN:
|
||||
raise RuntimeError("VESTI_BOT_TOKEN не задан (dry-run недоступен для сети)")
|
||||
return f"{API}/bot{BOT_TOKEN}/{method}"
|
||||
|
||||
|
||||
def channel_username(direction: str, lang: str) -> str:
|
||||
"""@dedinit_vesti_<direction>_<lang>_bot — по спеке; канал — тот же без _bot."""
|
||||
return f"@dedinit_vesti_{direction}_{lang}_bot"
|
||||
|
||||
|
||||
def _upload_photo(chat_id: str, photo_path: str, caption: str = "") -> int:
|
||||
"""Отправляет фото (медиа первым). Возвращает message_id."""
|
||||
with open(photo_path, "rb") as f:
|
||||
r = httpx.post(
|
||||
_bot_url("sendPhoto"),
|
||||
data={"chat_id": chat_id, "caption": caption[:1024]},
|
||||
files={"photo": f},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()["result"]["message_id"]
|
||||
|
||||
|
||||
def _send_message(chat_id: str, text: str, parse_mode: str = "HTML") -> int:
|
||||
r = httpx.post(
|
||||
_bot_url("sendMessage"),
|
||||
json={"chat_id": chat_id, "text": text, "parse_mode": parse_mode},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()["result"]["message_id"]
|
||||
|
||||
|
||||
def publish_card(card: dict, direction: str = "linux", lang: str = "ru", chat_id: str | None = None) -> dict:
|
||||
"""Публикует карточку {media, text} в канал. Возвращает {message_id, media_message_id}.
|
||||
|
||||
dry-run: если VESTI_BOT_TOKEN не задан — возвращает плейсхолдер (не отправляет).
|
||||
"""
|
||||
if not BOT_TOKEN:
|
||||
return {"message_id": 0, "media_message_id": 0, "dry_run": True}
|
||||
|
||||
chat = chat_id or channel_username(direction, lang)
|
||||
media_msg = None
|
||||
if card.get("media"):
|
||||
media_msg = _upload_photo(chat, card["media"], caption=card["text"][:1024])
|
||||
msg = _send_message(chat, card["text"])
|
||||
return {"message_id": msg, "media_message_id": media_msg, "dry_run": False}
|
||||
|
||||
|
||||
def publish_multi(card: dict, directions: list[str], lang: str = "ru") -> dict:
|
||||
"""Публикует карточку во ВСЕ каналы направлений. Возвращает {direction: {message_id, media_message_id, dry_run}}.
|
||||
|
||||
directions: список направлений (напр. ['linux', 'ai']).
|
||||
При пустом списке — публикация в направление по умолчанию (card['direction']).
|
||||
"""
|
||||
if not directions:
|
||||
directions = [card.get("direction") or "linux"]
|
||||
out = {}
|
||||
for d in directions:
|
||||
out[d] = publish_card(card, direction=d, lang=lang)
|
||||
return out
|
||||
|
||||
|
||||
def get_views(chat_id: str, message_id: int) -> int:
|
||||
"""Возвращает просмотры опубликованного сообщения через Bot API (getMessage → views)."""
|
||||
if not BOT_TOKEN:
|
||||
return 0
|
||||
try:
|
||||
r = httpx.get(_bot_url("getMessage"), params={"chat_id": chat_id, "message_id": message_id}, timeout=TIMEOUT)
|
||||
r.raise_for_status()
|
||||
m = r.json()["result"]
|
||||
return int(m.get("views") or m.get("forwards") or 0)
|
||||
except Exception as e:
|
||||
print(f" ! get_views failed {chat_id}/{message_id}: {e}")
|
||||
return 0
|
||||
|
||||
|
||||
def get_views_multi(directions: list[str], message_ids: dict, lang: str = "ru") -> dict:
|
||||
"""Собирает просмотры по каждому направлению. Возвращает {direction: views}."""
|
||||
out = {}
|
||||
for d in directions:
|
||||
mid = (message_ids.get(d) or {}).get("message_id") or 0
|
||||
if mid:
|
||||
out[d] = get_views(channel_username(d, lang), mid)
|
||||
else:
|
||||
out[d] = 0
|
||||
return out
|
||||
@@ -0,0 +1,81 @@
|
||||
# Форматирование карточки поста для Telegram (MUST: ≤4096 символов).
|
||||
# «Богатый репост»: [комментарий модератора] + полный текст поста + ссылка на оригинал.
|
||||
# Без служебной информации (направление/источник/просмотры) — она в вебе и бандле.
|
||||
# Текст — plain (не HTML): исходный текст поста не перекодируем, ссылки Telegram
|
||||
# распознаёт сам; предпросмотр ссылки отключается на уровне sendMessage.
|
||||
|
||||
import re
|
||||
|
||||
MAX_TEXT = 4096
|
||||
|
||||
# фото/видео, поддерживаемые Bot API sendPhoto/sendVideo
|
||||
MEDIA_EXTS = (".jpg", ".jpeg", ".png", ".gif", ".webp", ".mp4", ".mov", ".avi", ".mkv")
|
||||
|
||||
|
||||
def make_card(post: dict, comment: str | None = None) -> dict:
|
||||
"""Собирает карточку «богатого репоста». Возвращает {"media": path|None, "text": str}.
|
||||
|
||||
post: dict из БД (id, text, url, media_path, is_own, tg_channel, tg_post_id, ...).
|
||||
Формат текста:
|
||||
[комментарий модератора] — если задан, первым блоком
|
||||
[полный текст исходного поста] — без сниппета/суммари
|
||||
[ссылка на оригинал] — последней строкой (атрибуция для своих)
|
||||
|
||||
Текст НЕ содержит служебки (📁 направление/язык, 📰 источник, 👁 просмотры) —
|
||||
это был «агрегаторный» вид карточки.
|
||||
"""
|
||||
comment = (comment or "").strip()
|
||||
body = (post.get("text") or "").strip()
|
||||
|
||||
# ссылка на оригинал / атрибуция своего контента
|
||||
is_own = int(post.get("is_own") or 0) == 1
|
||||
orig = None
|
||||
if post.get("tg_channel") == "dedinit" and post.get("tg_post_id"):
|
||||
orig = f"https://t.me/dedinit/{post['tg_post_id']}"
|
||||
elif post.get("url"):
|
||||
orig = post.get("url")
|
||||
if is_own:
|
||||
tail = "✍️ Дед в АйТи (@dedinit)" + (f" · {orig}" if orig else "")
|
||||
elif orig:
|
||||
tail = f"🔗 Оригинал: {orig}"
|
||||
else:
|
||||
tail = None
|
||||
|
||||
parts = []
|
||||
if comment:
|
||||
parts.append(comment)
|
||||
if body:
|
||||
parts.append(body)
|
||||
if tail:
|
||||
parts.append(tail)
|
||||
if not parts:
|
||||
parts = ["(пост без текста)"]
|
||||
|
||||
text = "\n\n".join(parts)
|
||||
|
||||
if len(text) > MAX_TEXT:
|
||||
# обрезаем полный текст, но сохраняем ссылку/атрибуцию в конце
|
||||
tail_block = ("\n\n" + tail) if tail else ""
|
||||
budget = MAX_TEXT - len(tail_block) - 3
|
||||
body_cut = text[:budget].rstrip() + "…"
|
||||
text = body_cut + tail_block
|
||||
if len(text) > MAX_TEXT:
|
||||
text = text[: MAX_TEXT - 3] + "…"
|
||||
|
||||
# медиа: фото И видео (раньше отбрасывалось всё кроме картинок)
|
||||
media = post.get("media_path") or post.get("media_local") or None
|
||||
if media and not str(media).lower().endswith(MEDIA_EXTS):
|
||||
media = None
|
||||
if media:
|
||||
# нормализация пути: media_path в БД = 'media/<file>', но старые файлы
|
||||
# физически лежат в media/media/<file> (баг краулера до 2026-09-12).
|
||||
# Пробуем оба варианта, отдаём тот, что существует.
|
||||
import os as _os
|
||||
cands = [
|
||||
str(media),
|
||||
str(_os.path.join("media", str(media).replace("media/", "", 1)))
|
||||
if str(media).startswith("media/") else None,
|
||||
]
|
||||
found = next((c for c in cands if c and _os.path.exists(c)), None)
|
||||
media = found or media # если ни один не найден — оставляем как есть (publisher retry)
|
||||
return {"media": media, "text": text}
|
||||
Reference in New Issue
Block a user