Compare commits

...

5 Commits

14 changed files with 600 additions and 633 deletions

5
.gitignore vendored
View File

@@ -5,4 +5,7 @@ __pycache__
.gigaide .gigaide
keys.py keys.py
keys_*.py keys_*.py
!keys_example.py !keys_example.py
venv/
.env
*.pyc

Binary file not shown.

View File

@@ -0,0 +1,33 @@
[Unit]
Description=Linux Gaming News Bot
Documentation=https://github.com/xpamych/bot-news-linux-gaming
After=network.target network-online.target
Wants=network-online.target
[Service]
Type=simple
User=xpamych
Group=xpamych
WorkingDirectory=/home/xpamych/Yandex.Disk/IdeaProjects/bot-news-linux-gaming
ExecStart=/usr/bin/python3 /home/xpamych/Yandex.Disk/IdeaProjects/bot-news-linux-gaming/news-bot-modular.py
Restart=always
RestartSec=10
# Переменные окружения
Environment=PYTHONPATH=/home/xpamych/Yandex.Disk/IdeaProjects/bot-news-linux-gaming
Environment=PYTHONUNBUFFERED=1
# Ограничения безопасности
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=/home/xpamych/Yandex.Disk/IdeaProjects/bot-news-linux-gaming
# Логирование
StandardOutput=journal
StandardError=journal
SyslogIdentifier=news-bot
[Install]
WantedBy=multi-user.target

View File

@@ -131,10 +131,16 @@ class ContentProcessor:
def extract_links(self, text): def extract_links(self, text):
self.logger.debug("Извлекаем ссылки из текста") self.logger.debug("Извлекаем ссылки из текста")
url_pattern = re.compile(r'https?://\S+') # Улучшенное регулярное выражение, исключающее конечные знаки препинания
url_pattern = re.compile(r'https?://[^\s\)\]\}\>,;]+')
links = url_pattern.findall(text) links = url_pattern.findall(text)
self.logger.debug(f"Найденные ссылки: {links}") # Дополнительная очистка: убираем точки и запятые в конце
return links cleaned_links = []
for link in links:
link = link.rstrip('.,!?')
cleaned_links.append(link)
self.logger.debug(f"Найденные ссылки: {cleaned_links}")
return cleaned_links
def format_for_vk(self, content): def format_for_vk(self, content):
"""Форматирование контента для VK""" """Форматирование контента для VK"""

View File

@@ -80,14 +80,50 @@ class DiscordClient:
return None return None
async def send_message(self, channel, content): async def send_message(self, channel, content):
"""Отправка сообщения в Discord канал""" """Отправка сообщения в Discord канал с разбивкой длинных сообщений"""
if not self.is_enabled(): if not self.is_enabled():
return return
try: try:
# Разбиваем содержимое на части по 2000 символов (лимит Discord) # Discord лимит: 2000 символов
for i in range(0, len(content), 2000): max_length = 2000
await channel.send(content[i:i+2000])
if len(content) <= max_length:
# Короткое сообщение - отправляем как есть
await channel.send(content)
else:
# Длинное сообщение - разбиваем умно по строкам
self.logger.warning(f"Сообщение слишком длинное ({len(content)} символов), разбиваем на части")
parts = []
current_part = ""
for line in content.split('\n'):
# Если добавление этой строки превысит лимит
if len(current_part + line + '\n') > max_length:
if current_part:
parts.append(current_part.rstrip())
current_part = ""
current_part += line + '\n'
# Добавляем последнюю часть
if current_part:
parts.append(current_part.rstrip())
self.logger.info(f"Сообщение разбито на {len(parts)} частей для Discord")
# Отправляем каждую часть
for i, part in enumerate(parts, 1):
if len(parts) > 1:
part_content = f"[Часть {i}/{len(parts)}]\n\n{part}"
else:
part_content = part
await channel.send(part_content)
# Небольшая задержка между частями
if i < len(parts):
await asyncio.sleep(1)
self.logger.info("Сообщение успешно отправлено в Discord") self.logger.info("Сообщение успешно отправлено в Discord")
except Exception as e: except Exception as e:
self.logger.error(f"Ошибка отправки сообщения в Discord: {e}") self.logger.error(f"Ошибка отправки сообщения в Discord: {e}")
@@ -142,7 +178,7 @@ class DiscordClient:
await client.close() await client.close()
return return
# Публикуем новости # Публикуем новости в обратном порядке, чтобы новые оказались сверху в ленте
for topic_id, topic_title in reversed(list_for_public): for topic_id, topic_title in reversed(list_for_public):
from site_api import SiteAPI from site_api import SiteAPI
site_api = SiteAPI() site_api = SiteAPI()

127
install-service.sh Executable file
View File

@@ -0,0 +1,127 @@
#!/bin/bash
# Установочный скрипт для Linux Gaming News Bot
# Создает systemd service и настраивает его для автозапуска
set -e # Выход при любой ошибке
# Цвета для вывода
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Функции для цветного вывода
print_info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
print_success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
print_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# Проверка запуска от root
if [ "$EUID" -ne 0 ]; then
print_error "Скрипт должен быть запущен с правами root (sudo)"
print_info "Используйте: sudo ./install-service.sh"
exit 1
fi
# Определение текущего пользователя (не root)
REAL_USER=$(who am i | awk '{print $1}')
if [ -z "$REAL_USER" ]; then
REAL_USER=$(logname 2>/dev/null || echo $SUDO_USER)
fi
if [ -z "$REAL_USER" ]; then
print_error "Не удалось определить имя пользователя"
exit 1
fi
print_info "Установка сервиса для пользователя: $REAL_USER"
# Определение директории проекта
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$SCRIPT_DIR"
print_info "Директория проекта: $PROJECT_DIR"
# Проверка существования файлов
if [ ! -f "$PROJECT_DIR/news-bot-modular.py" ]; then
print_error "Не найден файл news-bot-modular.py в $PROJECT_DIR"
exit 1
fi
if [ ! -f "$PROJECT_DIR/bot-news-linux-gaming.service" ]; then
print_error "Не найден файл bot-news-linux-gaming.service в $PROJECT_DIR"
exit 1
fi
# Проверка наличия keys.py
if [ ! -f "$PROJECT_DIR/keys.py" ]; then
print_warning "Не найден файл keys.py с настройками"
print_info "Создайте keys.py на основе keys_example.py перед запуском сервиса:"
print_info " cp keys_example.py keys.py"
print_info " nano keys.py # заполните реальными ключами"
fi
# Создание временного файла сервиса с правильными путями
TEMP_SERVICE=$(mktemp)
sed "s|/home/xpamych/Yandex.Disk/IdeaProjects/bot-news-linux-gaming|$PROJECT_DIR|g" "$PROJECT_DIR/bot-news-linux-gaming.service" > "$TEMP_SERVICE"
sed -i "s|User=xpamych|User=$REAL_USER|g" "$TEMP_SERVICE"
sed -i "s|Group=xpamych|Group=$REAL_USER|g" "$TEMP_SERVICE"
print_info "Копирование systemd unit файла..."
cp "$TEMP_SERVICE" /etc/systemd/system/bot-news-linux-gaming.service
rm "$TEMP_SERVICE"
print_info "Установка прав доступа..."
chmod 644 /etc/systemd/system/bot-news-linux-gaming.service
chown root:root /etc/systemd/system/bot-news-linux-gaming.service
print_info "Перезагрузка systemd..."
systemctl daemon-reload
print_success "Сервис успешно установлен!"
print_info ""
print_info "Для управления сервисом используйте команды:"
print_info " sudo systemctl enable bot-news-linux-gaming # Включить автозапуск"
print_info " sudo systemctl start bot-news-linux-gaming # Запустить сервис"
print_info " sudo systemctl status bot-news-linux-gaming # Посмотреть статус"
print_info " sudo systemctl stop bot-news-linux-gaming # Остановить сервис"
print_info " sudo systemctl disable bot-news-linux-gaming # Отключить автозапуск"
print_info ""
print_info "Логи сервиса:"
print_info " sudo journalctl -u bot-news-linux-gaming -f # Следить за логами"
print_info " sudo journalctl -u bot-news-linux-gaming -n 50 # Последние 50 строк"
print_info ""
# Предложение сразу включить и запустить
read -p "Включить автозапуск и запустить сервис сейчас? (y/N): " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
print_info "Включение автозапуска..."
systemctl enable bot-news-linux-gaming
print_info "Запуск сервиса..."
systemctl start bot-news-linux-gaming
sleep 2
print_info "Статус сервиса:"
systemctl status bot-news-linux-gaming --no-pager
print_success "Сервис запущен и добавлен в автозагрузку!"
else
print_info "Сервис установлен, но не запущен. Запустите его командой:"
print_info " sudo systemctl enable --now bot-news-linux-gaming"
fi

View File

@@ -1,597 +0,0 @@
#!/usr/bin/env python3
import re
import sys
import time
import schedule
import asyncio
import discord
import logging
import colorlog
import requests
import html2text
import urllib.parse
from telethon import events
from bs4 import BeautifulSoup
from telethon.sync import TelegramClient
from telethon.errors import FloodWaitError
import keys
url_post = "https://linux-gaming.ru/posts.json"
url_news = f"https://linux-gaming.ru/c/news/{keys.cat_num}.json"
url_vk_post = "https://api.vk.com/method/wall.post"
url_vk_get = "https://api.vk.com/method/wall.get"
url_changelog = "https://gitlab.eterfund.ru/Castro-Fidel/PortWINE/raw/master/data_from_portwine/changelog_ru"
heads_site = {
"Content-Type": "application/json",
"Api-Key": keys.api_key_site,
"Api-Username": "linux-gaming"
}
params_get = {
'access_token': keys.user_token_vk,
'v': '5.199', # Версия API
'owner_id': str(keys.own_id),
'count': str(100),
'offset': str(0),
'filter': 'all'
}
logger = logging.getLogger()
logger.setLevel(logging.DEBUG)
handler = colorlog.StreamHandler()
handler.setFormatter(colorlog.ColoredFormatter(
'%(log_color)s%(asctime)s - %(levelname)s: %(message)s',
datefmt='%Y-%m-%d %H:%M:%S',
log_colors={
'DEBUG': 'cyan',
'INFO': 'green',
'WARNING': 'yellow',
'ERROR': 'red',
'CRITICAL': 'red,bg_white',
}
))
logger.addHandler(handler)
async def job():
matches_changelog, last_changelog, resp_changelog = resp_change()
check_version(matches_changelog, last_changelog, resp_changelog)
await check_discord_public()
check_vk_posts()
await check_tg_news()
def make_soup(resp_changelog):
logging.debug(f"Вызываем make_soup")
return BeautifulSoup(resp_changelog.text, 'html.parser')
def html_to_text(html_content):
logging.debug(f"Вызываем html_to_text")
logging.debug(f"HTML на входе {html_content}")
h = html2text.HTML2Text()
h.ignore_links = False # Сохранение ссылок
h.ignore_images = True # Игнорирование изображений
h.bypass_tables = True # Сохранение таблиц
h.reference_links = True # Сохранение оригинальных ссылок
markdown_text = h.handle(html_content)
logging.debug(f"Текст до обработки регулярками {markdown_text}")
# Удаление переносов строк из-за -
markdown_text = re.sub(r'-\s*\n\s*', '-', markdown_text, flags=re.DOTALL)
markdown_text = re.sub(r'-\s*\n*', '-', markdown_text, flags=re.DOTALL)
# Убираем переносы строк внутри круглых скобок ()
markdown_text = re.sub(r'\((.*?)\)', lambda x: '(' + x.group(1).replace('\n', ' ') + ')', markdown_text, flags=re.DOTALL)
# Убираем переносы строк внутри квадратных скобок []
markdown_text = re.sub(r'\[(.*?)\]', lambda x: '[' + x.group(1).replace('\n', ' ') + ']', markdown_text, flags=re.DOTALL)
# Удаление строк, содержащих '* * *'
markdown_text = re.sub(r'^.*\* \* \*.*$', '', markdown_text, flags=re.MULTILINE)
# Преобразование всех ссылок с параметрами URL
markdown_text = convert_links(markdown_text)
# Работа с #
patterns_to_remove = [
r'###',
r'##',
r'#',
r'\[scripts\]\(\/tag\/scripts\) version \d+ ',
r'##\[scripts\]\(\) version \d+ ',
r'\d{4}×\d{3} \d+ KB'
]
for pattern in patterns_to_remove:
markdown_text = re.sub(pattern, '', markdown_text)
# Удаление избыточных пустых строк после удаления строк
markdown_text = re.sub(r'\n\s*\n', '\n', markdown_text)
# Замена текстов типа "image1280×474 99.7 KB", "807×454 64.1 KB" на "."
markdown_text = re.sub(r'image\d+×\d+\s+\d+(\.\d+)?\s+KB', '.', markdown_text)
markdown_text = re.sub(r'\d+×\d+\s+\d+(\.\d+)?\s+KB', '.', markdown_text)
# Изменение ссылок без описания
markdown_text = re.sub(r'\[\]\((https:\/\/[^\)]+)\)', r'[.](\1)', markdown_text)
markdown_text = re.sub(r'\[\s]\((https:\/\/[^\)]+)\)', r'[.](\1)', markdown_text)
# Удаление дублирующихся ссылок
markdown_text = remove_duplicate_links(markdown_text)
# # Добавление переноса после "История изменений:"
# re.sub(r'^.*\* \* \*.*$', '', markdown_text)
# markdown_text = re.sub(r'История изменений:', r'\n', markdown_text)
# Удаление лишних отступов для строк, начинающихся с '*'
markdown_text = re.sub(r' \*', r'*', markdown_text)
# Перемещение ссылки на изображение в конец последней строки
image_link = "[.](https://linux-gaming.ru/uploads/default/original/1X/5cfa59077a5275971401fab0114e56f3ffdd0ec4.png)"
if image_link in markdown_text:
# Удаляем строку с изображением из текста
markdown_text = markdown_text.replace(image_link, '')
markdown_text = markdown_text + image_link
logging.debug(f"Текст после обработки {markdown_text}")
return markdown_text
def convert_links(text):
logging.debug(f"Входим в convert_links")
url_pattern = re.compile(r'https?://[^\s\)]+')
url_pattern = url_pattern.sub(lambda match: decode_url_params(match.group(0)), text)
logging.debug(f"Возврат url_pattern {url_pattern}")
return url_pattern
def decode_url_params(url):
logging.debug(f"Входим в decode_url_params")
parsed_url = urllib.parse.urlparse(url)
query_params = urllib.parse.parse_qs(parsed_url.query)
for key, values in query_params.items():
if key.lower() == 'to' and values:
return urllib.parse.unquote(values[0])
logging.debug(f"Возврат url {url}")
return url
def remove_empty_lines(text_data):
logging.debug(f"Входим в remove_empty_lines")
lines = text_data.splitlines()
non_empty_lines = [line for line in lines if line.strip()]
non_empty_lines = '\n'.join(non_empty_lines)
logging.debug(f"Возврат non_empty_lines {non_empty_lines}")
return non_empty_lines
def remove_markdown_links(markdown_text):
logging.debug(f"Входим в remove_markdown_links")
# Регулярное выражение для поиска Markdown-ссылок и замена их на только URL
markdown_text = re.sub(r'\[.*?\]\((https?://.*?)\)', r'\1' or r'(https?://.*?)\)', markdown_text)
logging.debug(f"Возврат markdown_text {markdown_text}")
return markdown_text
def remove_duplicate_links(text):
logging.debug(f"Входим в remove_duplicate_links")
seen_links = set()
def replace_link(match):
link = match.group(2)
if link in seen_links:
return ''
seen_links.add(link)
return match.group(0)
# Регулярное выражение для поиска Markdown-ссылок
link_pattern = re.compile(r'(\[.*?\]\((https:\/\/.*?)\))')
text = re.sub(link_pattern, replace_link, text)
logging.debug(f"Возвращаем text {text}")
return text
def extract_links(text):
logging.debug(f"Входим в extract_links")
# Регулярное выражение для поиска ссылок
url_pattern = re.compile(r'https?://\S+')
url_pattern = url_pattern.findall(text)
logging.debug(f"Возвращаем url_pattern {url_pattern}")
return url_pattern
def script_content(script_ver, next_version, resp_changelog):
logging.debug(f"Вход в script_content c версией стабильного скрипта {script_ver}")
soup = make_soup(resp_changelog)
page_text = str(soup)
page_text = page_text.replace("Вы можете помочь развитию проекта: https://linux-gaming.ru/donate/", '')
# Находим текст до определенного текста, тега или класса (например, до тега <hr>)
last_text = f"###Scripts version {next_version}### / stable"
last_text = str(last_text)
index_last_text = page_text.find(last_text)
if index_last_text != -1:
changelog_text_last = page_text[:index_last_text]
prev_text = f"###Scripts version {script_ver}### / stable"
index_script_ver = changelog_text_last.find(prev_text)
changelog_text = changelog_text_last[index_script_ver:]
changelog_text = re.sub(r'###Scripts version (\d+)### / Дата: (\d{2}\.\d{2}\.\d{4}) / Размер скачиваемого обновления: \d+ \S+', r'\1 - \2'":", changelog_text)
changelog_text = re.sub(r'###Scripts version (\d+)### / stable / Дата: (\d{2}\.\d{2}\.\d{4}) / Размер скачиваемого обновления: \d+ \S+', r'\1 - \2'":", changelog_text)
post_text = (f"-----------------------------\n") + changelog_text
site_text = (f"[center][img]/uploads/default/original/1X/5cfa59077a5275971401fab0114e56f3ffdd0ec4.png[/img][/center]\n{post_text}")
logging.debug(f"Сообщение на сайт {site_text}")
post_data = {
"title": f"Кумулятивное обновление скриптов {script_ver}",
"raw": site_text,
"category": keys.cat_num
}
logging.debug(f"Возвращаем post_text - {post_text}\n post_data - {post_data}")
return post_text, post_data, post_text
def news_content(post_id):
logging.debug(f"Запрос содержимого поста новости с ID: {post_id}")
response = response_get(f"https://linux-gaming.ru/t/{post_id}.json", heads_site)
if response and response.status_code == 200:
topic_data = response.json()
posts = topic_data.get('post_stream', {}).get('posts', [])
# Найти первый пост
for post in posts:
if post.get('post_number') == 1:
html_content = post.get('cooked', 'Нет содержимого')
text_data = html_to_text(html_content)
return text_data
logging.error(f"Первый пост не найден в теме с ID: {post_id}")
return None
else:
logging.error(f"Не удалось получить содержимое поста с ID: {post_id}")
return None
def response_get(url, heads_site=None):
try:
if heads_site == params_get:
return requests.get(url, params=params_get)
elif heads_site == heads_site:
return requests.get(url, headers=heads_site)
except requests.RequestException as err:
logging.error(f"Ошибка запроса {err}")
def resp_change():
resp_changelog = response_get(url_changelog, heads_site)
if resp_changelog and resp_changelog.status_code == 200:
matches_changelog = re.findall(r'###Scripts version (\d+)### / stable', resp_changelog.text)
logging.debug(f"Найдены версии в истории изменений: {matches_changelog}")
last_changelog = int(max(matches_changelog))
logging.info(f"Последняя стабильная версия в истории изменений: {last_changelog}")
return matches_changelog, last_changelog, resp_changelog
else:
logging.error(
f'Ошибка при запросе changelog: {resp_changelog.status_code if resp_changelog else "No Response"}')
return None, None
def news():
resp_topics = response_get(url_news, heads_site)
if resp_topics.status_code == 200:
data = resp_topics.json()
topics = data['topic_list']['topics']
list_titles_and_ids = [(topic['id'], str(topic['title'])) for topic in topics]
filtered_list_titles_and_ids = [(id, title) for id, title in list_titles_and_ids if not title == ('Описание '
'категории '
'«Новости»')]
return filtered_list_titles_and_ids
else:
logging.error(f"Ошибка при запросе тем с сайта: {resp_topics.status_code if resp_topics else 'Нет доступа к сайту'}")
return []
def site_post(url, headers, json):
while True:
title = json.get('title')
try:
resp_post = requests.post(url=url, headers=headers, json=json)
if resp_post.status_code == 200:
logging.info("Новость опубликована на сайте!")
return resp_post
elif resp_post.status_code == 422:
logging.warning(f'Новость "{title}" уже опубликована: {resp_post.status_code}')
return resp_post
else:
logging.error(f'Ошибка при отправке новости "{title}" на сайт: {resp_post.status_code}')
except requests.RequestException as error:
logging.error(f'Ошибка при отправке новости "{title}" на сайт: {error}')
time.sleep(900)
def check_version(matches_changelog, last_changelog, resp_changelog):
list_titles_and_ids = news()
pattern = re.compile(r'Кумулятивное обновление скриптов (\d+)')
def extract_number(title):
match = pattern.search(title)
if match:
return int(match.group(1))
return None
def posting_news():
list_new_ver = []
for script_ver, next_version in zip(reversed(matches_changelog[:-1]), reversed(matches_changelog[1:])):
logging.info(f"Найдена новая версия скрипта {script_ver}")
changelog_text, post_data, params = script_content(script_ver, next_version, resp_changelog)
if post_data:
logging.debug(f"Публикуем {post_data}")
site_post(url_post, heads_site, post_data)
if not list_new_ver:
logging.warning(f"Не найдена новая стабильная версия скрипта")
numbers = [extract_number(title) for _, title in list_titles_and_ids if extract_number(title) is not None]
if numbers:
last_topics_script = max(numbers)
logging.info(f"Последняя новость на сайте о версии: {last_topics_script}")
if last_topics_script < last_changelog:
posting_news()
else:
logging.warning("Нет новых версий скриптов PortProton")
else:
logging.warning("На сайте нет новостей о скриптах")
posting_news()
async def discord_post(post_text, client):
channel = client.get_channel(keys.dicord_channel)
await channel.send(f"{post_text}")
async def get_discord_messages(client_discord, channel_id):
channel = client_discord.get_channel(channel_id)
if not channel:
logging.error(f"ID канала Discord {channel_id} не существует")
return []
messages = []
async for message in channel.history(limit=100):
logging.debug(message)
messages.append(message.content)
pattern = re.compile(r'----------------------------------------------------------\n### (.*?)\t\n', re.DOTALL)
for message in messages:
matches = pattern.findall(message)
if matches:
messages.extend(matches)
logging.debug(f"Найдены сообщения в дискорде: {messages}")
return messages
async def check_discord_public():
intents = discord.Intents.default()
intents.messages = True
client_discord = discord.Client(intents=intents)
@client_discord.event
async def on_ready():
logging.debug(f"Успешный логин в discord {client_discord.user}")
channel_id = keys.dicord_channel
discord_messages = await get_discord_messages(client_discord, channel_id)
list_titles_and_ids = news()
if list_titles_and_ids:
list_for_public = []
for topic_id, topic_title in list_titles_and_ids:
if topic_title not in discord_messages and topic_id > keys.start_topic_id:
list_for_public.append((topic_id, topic_title))
if not list_for_public:
logging.warning(f"Новостей для публикации в дискорд нет")
await client_discord.close()
else:
logging.info(f"Новости для публикации в дискорд: {list_for_public}")
channel = client_discord.get_channel(channel_id)
if not channel:
logging.error(f"ID канала Discord {channel_id} не существует")
await client_discord.close()
return
for topic_id, topic_title in reversed(list_for_public):
text_data = news_content(topic_id)
if text_data:
content = f"----------------------------------------------------------\n### {topic_title}\t\n" + text_data + "\n" + "@here"
# Разбиваем содержимое на части по 4000 символов
for i in range(0, len(content), 2000):
await channel.send(content[i:i+2000])
time.sleep(1.0)
await client_discord.close()
await client_discord.start(keys.discord_token)
def vk_post(url, post_text, links=None):
params_post = {
'access_token': keys.api_key_vk,
'v': '5.199', # Версия API VK
'owner_id': str(keys.own_id)
# Дополнительные параметры можно добавить здесь
}
post_text = re.sub(r'\* ', '', post_text)
print(post_text)
post_text = re.sub(r'', '', post_text)
post_text = re.sub(r'', '', post_text)
data = {
'message': f'{post_text}' # Перемещаем текст статьи в тело POST-запроса
}
if links:
params_post['attachments'] = links
try:
# Отправляем POST-запрос к VK API
resp_post = requests.post(url=url, params=params_post, data=data)
if resp_post.status_code == 200:
logging.info("Сообщение успешно опубликовано.")
logging.info(resp_post.json()) # Выводим ответ сервера в формате JSON
else:
logging.error(f"Ошибка при публикации сообщения в ВК:, {resp_post.status_code} - {resp_post.reason}")
return resp_post
except requests.RequestException as err:
logging.error(f"VK post failed: {err}")
return None
def get_vk_topics():
wall_posts = []
while True:
wall_data = response_get(url_vk_get, params_get)
wall_data_json = wall_data.json()
if 'error' in wall_data_json:
error_code = wall_data_json['error']['error_code']
error_msg = wall_data_json['error']['error_msg']
logging.error(f"Ошибка {error_code}: {error_msg}")
sys.exit(f"Ошибка {error_code}: {error_msg}")
items = wall_data_json.get('response', {}).get('items', [])
if not items:
logging.warning("Постов на стене нет")
break
wall_posts.extend((post['text'] for post in items if 'text' in post))
if len(items) < 100:
break
params_get['offset'] = str(int(params_get['offset']) + 100)
pattern = re.compile(r'----------------------------------------------------------\n### (.*?)\t\n', re.DOTALL)
for message in wall_posts:
matches = pattern.findall(message)
if matches:
wall_posts.extend(matches)
logging.debug(f"Найдены посты в ВК: {wall_posts}")
return wall_posts
def check_vk_posts():
vk_posts = get_vk_topics()
if not vk_posts:
logging.warning(f"Постов на стене нет {vk_posts}")
else:
list_titles_and_ids = news()
list_titles_and_ids = [pair for pair in list_titles_and_ids if pair[0] >= keys.start_topic_id]
if list_titles_and_ids:
list_for_public = []
# Отфильтровываем посты ниже определенного ID статьи
for topic_id, topic_title in list_titles_and_ids:
# Сравнение заголовков с текстами постов
if not any(topic_title in vk_posts for vk_posts in vk_posts):
list_for_public.append((topic_id, topic_title))
if not list_for_public:
logging.warning(f"Новостей для публикации в ВК нет")
else:
# Отфильтровываем посты ниже определенного ID статьи
for topic_id, topic_title in reversed(list_for_public):
logging.info(f"Новости для публикации в ВК: {list_for_public}")
text_data = news_content(topic_id)
if text_data:
content = f"{topic_title}\t\n" + text_data + "\n"
content = remove_markdown_links(content)
content = re.sub(r'https://linux-gaming.ru/uploads/default/original/1X/5cfa59077a5275971401fab0114e56f3ffdd0ec4.png', '\n', content, flags=re.DOTALL)
links = extract_links(content)
if "Кумулятивное обновление скриптов" in topic_title:
# Пример добавления изображения с постом
vk_post(url_vk_post, content, "photo-99238527_457244491")
else:
if links:
vk_post(url_vk_post, content, links)
else:
vk_post(url_vk_post, content)
else:
logging.warning(f"Не удалось получить текст новости {topic_id}")
time.sleep(1.0)
else:
logging.warning(f"Новостей для публикации в ВК нет")
def tg_post(post_text, client_tg):
# Отправка сообщения
client_tg.send_message(keys.channel_username_tg, post_text)
# Завершение сеанса
client_tg.disconnect()
async def get_tg_messages(client_tg, channel_username_tg):
messages = []
async for message in client_tg.iter_messages(channel_username_tg, limit=100):
if message.text: # Проверка на NoneType
logging.debug(f"Найдены сообщения в Telegram канале {message.text}")
messages.append(message.text)
return messages
async def check_tg_news():
session_file = 'LG_news'
client_tg = TelegramClient(session_file, keys.api_id_tg, keys.api_hash_tg)
async with client_tg:
await client_tg.start()
tg_messages = await get_tg_messages(client_tg, keys.channel_username_tg)
list_titles_and_ids = news()
if list_titles_and_ids:
list_for_public = []
for topic_id, topic_title in list_titles_and_ids:
if all(topic_title not in (msg or '') for msg in tg_messages) and topic_id > keys.start_topic_id:
list_for_public.append((topic_id, topic_title))
if not list_for_public:
logging.warning(f"Новостей для публикации в Telegram нет")
else:
logging.info(f"Новости для публикации в Telegram: {list_for_public}")
for topic_id, topic_title in reversed(list_for_public):
text_data = news_content(topic_id)
if text_data:
content = f"### {topic_title}\t\n" + text_data + "\n"
while True:
try:
await client_tg.send_message(keys.channel_username_tg, content)
break
except FloodWaitError as e:
logging.warning(f"Flood wait error: нужно подождать {e.seconds} секунд.")
await asyncio.sleep(e.seconds) # Ждем
time.sleep(1.0) # указанное время перед повторной попыткой
def run_job():
asyncio.run(job())
if __name__ == '__main__':
# Выполняем задачу немедленно при старте
run_job()
# Планируем выполнение задачи каждые N минут
schedule.every(30).minutes.do(check_vk_posts).do(run_job)
logger.info("Запуск планировщика задач")
while True:
schedule.run_pending()
time.sleep(5)

23
requirements.txt Normal file
View File

@@ -0,0 +1,23 @@
# Основные зависимости для bot-news-linux-gaming
# Обновлено: 2025-08-18
# Telegram клиент
Telethon>=1.40.0
# HTTP запросы
requests>=2.32.0
# Планировщик задач
schedule>=1.2.0
# Логирование с цветами
colorlog>=6.9.0
# Конвертация HTML в текст
html2text>=2025.4.0
# Парсинг HTML
beautifulsoup4>=4.13.0
# Discord клиент (опционально)
# discord.py>=2.3.0

15
run_bot.sh Executable file
View File

@@ -0,0 +1,15 @@
#!/bin/bash
# Скрипт для запуска бота в виртуальном окружении
# Использование: ./run_bot.sh
# Проверка наличия виртуального окружения
if [ ! -d "venv" ]; then
echo "Виртуальное окружение не найдено!"
echo "Сначала запустите: bash setup_venv.sh"
exit 1
fi
# Запуск бота через виртуальное окружение
echo "Запуск бота..."
./venv/bin/python3 news-bot-modular.py

155
service-control.sh Executable file
View File

@@ -0,0 +1,155 @@
#!/bin/bash
# Простой скрипт управления Linux Gaming News Bot
SERVICE_NAME="bot-news-linux-gaming"
# Цвета для вывода
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
print_info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
print_success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
print_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# Функция показа статуса
show_status() {
print_info "Статус сервиса $SERVICE_NAME:"
systemctl status $SERVICE_NAME --no-pager -l
}
# Функция показа логов
show_logs() {
local lines=${1:-50}
print_info "Последние $lines строк логов:"
sudo journalctl -u $SERVICE_NAME -n $lines --no-pager
}
# Функция следения за логами
follow_logs() {
print_info "Следование за логами (Ctrl+C для выхода):"
sudo journalctl -u $SERVICE_NAME -f
}
# Функция показа помощи
show_help() {
echo "Управление Linux Gaming News Bot"
echo ""
echo "Использование: $0 [команда]"
echo ""
echo "Команды:"
echo " start - Запустить сервис"
echo " stop - Остановить сервис"
echo " restart - Перезапустить сервис"
echo " status - Показать статус сервиса"
echo " enable - Включить автозапуск"
echo " disable - Отключить автозапуск"
echo " logs - Показать последние логи"
echo " logs N - Показать последние N строк логов"
echo " follow - Следить за логами в реальном времени"
echo " install - Установить сервис (требует sudo)"
echo " uninstall - Удалить сервис (требует sudo)"
echo " help - Показать эту справку"
echo ""
}
# Проверка существования сервиса
check_service_exists() {
if ! systemctl list-unit-files | grep -q "^$SERVICE_NAME.service"; then
print_error "Сервис $SERVICE_NAME не установлен"
print_info "Запустите: sudo ./install-service.sh"
exit 1
fi
}
# Основная логика
case "${1}" in
"start")
check_service_exists
print_info "Запуск сервиса..."
sudo systemctl start $SERVICE_NAME
show_status
;;
"stop")
check_service_exists
print_info "Остановка сервиса..."
sudo systemctl stop $SERVICE_NAME
show_status
;;
"restart")
check_service_exists
print_info "Перезапуск сервиса..."
sudo systemctl restart $SERVICE_NAME
show_status
;;
"status")
check_service_exists
show_status
;;
"enable")
check_service_exists
print_info "Включение автозапуска..."
sudo systemctl enable $SERVICE_NAME
print_success "Автозапуск включен"
;;
"disable")
check_service_exists
print_info "Отключение автозапуска..."
sudo systemctl disable $SERVICE_NAME
print_success "Автозапуск отключен"
;;
"logs")
check_service_exists
if [ -n "$2" ] && [[ "$2" =~ ^[0-9]+$ ]]; then
show_logs $2
else
show_logs
fi
;;
"follow")
check_service_exists
follow_logs
;;
"install")
if [ ! -f "./install-service.sh" ]; then
print_error "Файл install-service.sh не найден"
exit 1
fi
sudo ./install-service.sh
;;
"uninstall")
if [ ! -f "./uninstall-service.sh" ]; then
print_error "Файл uninstall-service.sh не найден"
exit 1
fi
sudo ./uninstall-service.sh
;;
"help"|"--help"|"-h")
show_help
;;
"")
print_warning "Команда не указана"
show_help
exit 1
;;
*)
print_error "Неизвестная команда: $1"
show_help
exit 1
;;
esac

40
setup_venv.sh Executable file
View File

@@ -0,0 +1,40 @@
#!/bin/bash
# Скрипт установки виртуального окружения для bot-news-linux-gaming
# Запускать на сервере: bash setup_venv.sh
echo "=== Установка виртуального окружения для бота ==="
# 1. Установка python3-venv если не установлен
echo "Проверка наличия python3-venv..."
sudo apt update
sudo apt install -y python3-venv python3-full
# 2. Создание виртуального окружения
echo "Создание виртуального окружения..."
python3 -m venv venv
# 3. Активация виртуального окружения
echo "Активация виртуального окружения..."
source venv/bin/activate
# 4. Обновление pip
echo "Обновление pip..."
pip install --upgrade pip
# 5. Установка зависимостей
echo "Установка зависимостей..."
pip install -r requirements.txt
# 6. Удаление старых сессий
echo "Удаление старых файлов сессий..."
rm -f *.session
echo "=== Установка завершена ==="
echo ""
echo "Для запуска бота используйте:"
echo " source venv/bin/activate"
echo " python3 news-bot-modular.py"
echo ""
echo "Или запускайте напрямую:"
echo " ./venv/bin/python3 news-bot-modular.py"

View File

@@ -82,37 +82,81 @@ class TelegramNewsClient:
return None return None
async def send_message(self, client, channel_username, content): async def send_message(self, client, channel_username, content):
"""Отправка сообщения в Telegram канал/топик с обработкой flood wait""" """Отправка сообщения в Telegram канал/топик с обработкой flood wait и длинных сообщений"""
try: try:
# Получаем entity канала # Получаем entity канала
entity = await client.get_entity(channel_username) entity = await client.get_entity(channel_username)
while True: # Telegram лимит: 4096 символов
try: max_length = 4096
# Если указан topic_id, отправляем в топик
if self.config['topic_id']: # Если сообщение слишком длинное, разбиваем его
self.logger.debug(f"Отправка в топик {self.config['topic_id']}") if len(content) > max_length:
await client.send_message( self.logger.warning(f"Сообщение слишком длинное ({len(content)} символов), разбиваем на части")
entity,
content, # Разбиваем по параграфам, чтобы не резать посередине слов
reply_to=self.config['topic_id'] parts = []
) current_part = ""
self.logger.info(f"Сообщение успешно отправлено в Telegram топик {self.config['topic_id']}")
for line in content.split('\n'):
# Если добавление этой строки превысит лимит
if len(current_part + line + '\n') > max_length:
if current_part:
parts.append(current_part.rstrip())
current_part = ""
current_part += line + '\n'
# Добавляем последнюю часть
if current_part:
parts.append(current_part.rstrip())
self.logger.info(f"Сообщение разбито на {len(parts)} частей")
# Отправляем каждую часть
for i, part in enumerate(parts, 1):
if len(parts) > 1:
part_content = f"[Часть {i}/{len(parts)}]\n\n{part}"
else: else:
# Обычная отправка в канал part_content = part
await client.send_message(entity, content)
self.logger.info("Сообщение успешно отправлено в Telegram канал") await self._send_single_message(client, entity, part_content)
break
except FloodWaitError as e: # Небольшая задержка между частями
self.logger.warning(f"Flood wait error: нужно подождать {e.seconds} секунд") if i < len(parts):
await asyncio.sleep(e.seconds) await asyncio.sleep(1)
except Exception as e: else:
self.logger.error(f"Ошибка отправки сообщения в Telegram: {e}") # Обычная отправка
break await self._send_single_message(client, entity, content)
except Exception as e: except Exception as e:
self.logger.error(f"Ошибка получения entity канала '{channel_username}': {e}") self.logger.error(f"Ошибка получения entity канала '{channel_username}': {e}")
self.logger.info("Убедитесь, что имя канала указано правильно и бот имеет доступ") self.logger.info("Убедитесь, что имя канала указано правильно и бот имеет доступ")
async def _send_single_message(self, client, entity, content):
"""Отправка одного сообщения с обработкой flood wait"""
while True:
try:
# Если указан topic_id, отправляем в топик
if self.config['topic_id']:
self.logger.debug(f"Отправка в топик {self.config['topic_id']}")
await client.send_message(
entity,
content,
reply_to=self.config['topic_id']
)
self.logger.info(f"Сообщение успешно отправлено в Telegram топик {self.config['topic_id']}")
else:
# Обычная отправка в канал
await client.send_message(entity, content)
self.logger.info("Сообщение успешно отправлено в Telegram канал")
break
except FloodWaitError as e:
self.logger.warning(f"Flood wait error: нужно подождать {e.seconds} секунд")
await asyncio.sleep(e.seconds)
except Exception as e:
self.logger.error(f"Ошибка отправки сообщения в Telegram: {e}")
raise
async def check_and_publish_news(self, news_list): async def check_and_publish_news(self, news_list):
"""Проверка и публикация новостей в Telegram""" """Проверка и публикация новостей в Telegram"""
self.logger.info("Начинаем проверку новостей для Telegram") self.logger.info("Начинаем проверку новостей для Telegram")
@@ -151,7 +195,7 @@ class TelegramNewsClient:
self.logger.info(f"Новости для публикации в Telegram: {list_for_public}") self.logger.info(f"Новости для публикации в Telegram: {list_for_public}")
# Публикуем новости # Публикуем новости в обратном порядке, чтобы новые оказались сверху в ленте
for topic_id, topic_title in reversed(list_for_public): for topic_id, topic_title in reversed(list_for_public):
from site_api import SiteAPI from site_api import SiteAPI
site_api = SiteAPI() site_api = SiteAPI()

68
uninstall-service.sh Executable file
View File

@@ -0,0 +1,68 @@
#!/bin/bash
# Скрипт удаления Linux Gaming News Bot systemd service
set -e # Выход при любой ошибке
# Цвета для вывода
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Функции для цветного вывода
print_info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
print_success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
print_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# Проверка запуска от root
if [ "$EUID" -ne 0 ]; then
print_error "Скрипт должен быть запущен с правами root (sudo)"
print_info "Используйте: sudo ./uninstall-service.sh"
exit 1
fi
SERVICE_NAME="bot-news-linux-gaming"
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
print_info "Удаление сервиса $SERVICE_NAME..."
# Остановка сервиса если он запущен
if systemctl is-active --quiet $SERVICE_NAME; then
print_info "Остановка сервиса..."
systemctl stop $SERVICE_NAME
fi
# Отключение автозапуска если включен
if systemctl is-enabled --quiet $SERVICE_NAME; then
print_info "Отключение автозапуска..."
systemctl disable $SERVICE_NAME
fi
# Удаление файла сервиса
if [ -f "$SERVICE_FILE" ]; then
print_info "Удаление файла сервиса..."
rm "$SERVICE_FILE"
else
print_warning "Файл сервиса не найден: $SERVICE_FILE"
fi
# Перезагрузка systemd
print_info "Перезагрузка systemd..."
systemctl daemon-reload
print_success "Сервис $SERVICE_NAME успешно удален!"
print_info "Файлы проекта не затронуты и остались на месте."

View File

@@ -107,11 +107,25 @@ class VKClient:
for attempt in range(max_retries): for attempt in range(max_retries):
try: try:
response = requests.post(url=URL_VK_POST, params=params_post, data=data) response = requests.post(url=URL_VK_POST, params=params_post, data=data)
response_json = response.json()
if response.status_code == 200: if response.status_code == 200:
self.logger.info("Сообщение успешно опубликовано в VK") # Проверяем наличие ошибки в ответе VK API
self.logger.debug(response.json()) if 'error' in response_json:
return response error_code = response_json['error']['error_code']
error_msg = response_json['error']['error_msg']
self.logger.error(f"Ошибка VK API {error_code}: {error_msg}")
if 'request_params' in response_json['error']:
self.logger.debug(f"Параметры запроса: {response_json['error']['request_params']}")
# Не повторяем попытки при ошибках валидации
if error_code == 100: # Invalid parameter
return None
elif 'response' in response_json:
self.logger.info("Сообщение успешно опубликовано в VK")
self.logger.debug(f"ID поста: {response_json['response'].get('post_id', 'неизвестен')}")
return response
else:
self.logger.warning(f"Неожиданный формат ответа VK: {response_json}")
else: else:
self.logger.warning(f"Ошибка при публикации в VK: {response.status_code} - {response.reason} (попытка {attempt + 1}/{max_retries})") self.logger.warning(f"Ошибка при публикации в VK: {response.status_code} - {response.reason} (попытка {attempt + 1}/{max_retries})")
@@ -155,7 +169,7 @@ class VKClient:
self.logger.info(f"Новости для публикации в VK: {list_for_public}") self.logger.info(f"Новости для публикации в VK: {list_for_public}")
# Публикуем новости # Публикуем новости в обратном порядке, чтобы новые оказались сверху в ленте
for topic_id, topic_title in reversed(list_for_public): for topic_id, topic_title in reversed(list_for_public):
from site_api import SiteAPI from site_api import SiteAPI
site_api = SiteAPI() site_api = SiteAPI()