From 82e51f12a910883208ac7b6ce01e2e43a63b6f10 Mon Sep 17 00:00:00 2001 From: Dmitry Titenkov Date: Tue, 27 Jan 2026 13:20:32 +0300 Subject: [PATCH 01/27] parser --- satire_pulp_parser/__init__.py | 0 satire_pulp_parser/items.py | 12 +++ satire_pulp_parser/middlewares.py | 100 ++++++++++++++++++++++ satire_pulp_parser/pipelines.py | 13 +++ satire_pulp_parser/settings.py | 87 +++++++++++++++++++ satire_pulp_parser/spiders/__init__.py | 4 + satire_pulp_parser/spiders/satire_pulp.py | 28 ++++++ 7 files changed, 244 insertions(+) create mode 100644 satire_pulp_parser/__init__.py create mode 100644 satire_pulp_parser/items.py create mode 100644 satire_pulp_parser/middlewares.py create mode 100644 satire_pulp_parser/pipelines.py create mode 100644 satire_pulp_parser/settings.py create mode 100644 satire_pulp_parser/spiders/__init__.py create mode 100644 satire_pulp_parser/spiders/satire_pulp.py diff --git a/satire_pulp_parser/__init__.py b/satire_pulp_parser/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/satire_pulp_parser/items.py b/satire_pulp_parser/items.py new file mode 100644 index 0000000..f95d9ea --- /dev/null +++ b/satire_pulp_parser/items.py @@ -0,0 +1,12 @@ +# Define here the models for your scraped items +# +# See documentation in: +# https://docs.scrapy.org/en/latest/topics/items.html + +import scrapy + + +class SatirePulpParserItem(scrapy.Item): + # define the fields for your item here like: + # name = scrapy.Field() + pass diff --git a/satire_pulp_parser/middlewares.py b/satire_pulp_parser/middlewares.py new file mode 100644 index 0000000..9d0059f --- /dev/null +++ b/satire_pulp_parser/middlewares.py @@ -0,0 +1,100 @@ +# Define here the models for your spider middleware +# +# See documentation in: +# https://docs.scrapy.org/en/latest/topics/spider-middleware.html + +from scrapy import signals + +# useful for handling different item types with a single interface +from itemadapter import ItemAdapter + + +class SatirePulpParserSpiderMiddleware: + # Not all methods need to be defined. If a method is not defined, + # scrapy acts as if the spider middleware does not modify the + # passed objects. + + @classmethod + def from_crawler(cls, crawler): + # This method is used by Scrapy to create your spiders. + s = cls() + crawler.signals.connect(s.spider_opened, signal=signals.spider_opened) + return s + + def process_spider_input(self, response, spider): + # Called for each response that goes through the spider + # middleware and into the spider. + + # Should return None or raise an exception. + return None + + def process_spider_output(self, response, result, spider): + # Called with the results returned from the Spider, after + # it has processed the response. + + # Must return an iterable of Request, or item objects. + for i in result: + yield i + + def process_spider_exception(self, response, exception, spider): + # Called when a spider or process_spider_input() method + # (from other spider middleware) raises an exception. + + # Should return either None or an iterable of Request or item objects. + pass + + async def process_start(self, start): + # Called with an async iterator over the spider start() method or the + # matching method of an earlier spider middleware. + async for item_or_request in start: + yield item_or_request + + def spider_opened(self, spider): + spider.logger.info("Spider opened: %s" % spider.name) + + +class SatirePulpParserDownloaderMiddleware: + # Not all methods need to be defined. If a method is not defined, + # scrapy acts as if the downloader middleware does not modify the + # passed objects. + + @classmethod + def from_crawler(cls, crawler): + # This method is used by Scrapy to create your spiders. + s = cls() + crawler.signals.connect(s.spider_opened, signal=signals.spider_opened) + return s + + def process_request(self, request, spider): + # Called for each request that goes through the downloader + # middleware. + + # Must either: + # - return None: continue processing this request + # - or return a Response object + # - or return a Request object + # - or raise IgnoreRequest: process_exception() methods of + # installed downloader middleware will be called + return None + + def process_response(self, request, response, spider): + # Called with the response returned from the downloader. + + # Must either; + # - return a Response object + # - return a Request object + # - or raise IgnoreRequest + return response + + def process_exception(self, request, exception, spider): + # Called when a download handler or a process_request() + # (from other downloader middleware) raises an exception. + + # Must either: + # - return None: continue processing this exception + # - return a Response object: stops process_exception() chain + # - return a Request object: stops process_exception() chain + pass + + def spider_opened(self, spider): + spider.logger.info("Spider opened: %s" % spider.name) diff --git a/satire_pulp_parser/pipelines.py b/satire_pulp_parser/pipelines.py new file mode 100644 index 0000000..64c2f02 --- /dev/null +++ b/satire_pulp_parser/pipelines.py @@ -0,0 +1,13 @@ +# Define your item pipelines here +# +# Don't forget to add your pipeline to the ITEM_PIPELINES setting +# See: https://docs.scrapy.org/en/latest/topics/item-pipeline.html + + +# useful for handling different item types with a single interface +from itemadapter import ItemAdapter + + +class SatirePulpParserPipeline: + def process_item(self, item, spider): + return item diff --git a/satire_pulp_parser/settings.py b/satire_pulp_parser/settings.py new file mode 100644 index 0000000..6125ab5 --- /dev/null +++ b/satire_pulp_parser/settings.py @@ -0,0 +1,87 @@ +# Scrapy settings for satire_pulp_parser project +# +# For simplicity, this file contains only settings considered important or +# commonly used. You can find more settings consulting the documentation: +# +# https://docs.scrapy.org/en/latest/topics/settings.html +# https://docs.scrapy.org/en/latest/topics/downloader-middleware.html +# https://docs.scrapy.org/en/latest/topics/spider-middleware.html + +BOT_NAME = "satire_pulp_parser" + +SPIDER_MODULES = ["satire_pulp_parser.spiders"] +NEWSPIDER_MODULE = "satire_pulp_parser.spiders" + +ADDONS = {} + + +# Crawl responsibly by identifying yourself (and your website) on the user-agent +#USER_AGENT = "satire_pulp_parser (+http://www.yourdomain.com)" + +# Obey robots.txt rules +ROBOTSTXT_OBEY = True + +# Concurrency and throttling settings +#CONCURRENT_REQUESTS = 16 +CONCURRENT_REQUESTS_PER_DOMAIN = 1 +DOWNLOAD_DELAY = 1 + +# Disable cookies (enabled by default) +#COOKIES_ENABLED = False + +# Disable Telnet Console (enabled by default) +#TELNETCONSOLE_ENABLED = False + +# Override the default request headers: +#DEFAULT_REQUEST_HEADERS = { +# "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", +# "Accept-Language": "en", +#} + +# Enable or disable spider middlewares +# See https://docs.scrapy.org/en/latest/topics/spider-middleware.html +#SPIDER_MIDDLEWARES = { +# "satire_pulp_parser.middlewares.SatirePulpParserSpiderMiddleware": 543, +#} + +# Enable or disable downloader middlewares +# See https://docs.scrapy.org/en/latest/topics/downloader-middleware.html +#DOWNLOADER_MIDDLEWARES = { +# "satire_pulp_parser.middlewares.SatirePulpParserDownloaderMiddleware": 543, +#} + +# Enable or disable extensions +# See https://docs.scrapy.org/en/latest/topics/extensions.html +#EXTENSIONS = { +# "scrapy.extensions.telnet.TelnetConsole": None, +#} + +# Configure item pipelines +# See https://docs.scrapy.org/en/latest/topics/item-pipeline.html +#ITEM_PIPELINES = { +# "satire_pulp_parser.pipelines.SatirePulpParserPipeline": 300, +#} + +# Enable and configure the AutoThrottle extension (disabled by default) +# See https://docs.scrapy.org/en/latest/topics/autothrottle.html +#AUTOTHROTTLE_ENABLED = True +# The initial download delay +#AUTOTHROTTLE_START_DELAY = 5 +# The maximum download delay to be set in case of high latencies +#AUTOTHROTTLE_MAX_DELAY = 60 +# The average number of requests Scrapy should be sending in parallel to +# each remote server +#AUTOTHROTTLE_TARGET_CONCURRENCY = 1.0 +# Enable showing throttling stats for every response received: +#AUTOTHROTTLE_DEBUG = False + +# Enable and configure HTTP caching (disabled by default) +# See https://docs.scrapy.org/en/latest/topics/downloader-middleware.html#httpcache-middleware-settings +#HTTPCACHE_ENABLED = True +#HTTPCACHE_EXPIRATION_SECS = 0 +#HTTPCACHE_DIR = "httpcache" +#HTTPCACHE_IGNORE_HTTP_CODES = [] +#HTTPCACHE_STORAGE = "scrapy.extensions.httpcache.FilesystemCacheStorage" + +# Set settings whose default value is deprecated to a future-proof value +FEED_EXPORT_ENCODING = "utf-8" diff --git a/satire_pulp_parser/spiders/__init__.py b/satire_pulp_parser/spiders/__init__.py new file mode 100644 index 0000000..ebd689a --- /dev/null +++ b/satire_pulp_parser/spiders/__init__.py @@ -0,0 +1,4 @@ +# This package will contain the spiders of your Scrapy project +# +# Please refer to the documentation for information on how to create and manage +# your spiders. diff --git a/satire_pulp_parser/spiders/satire_pulp.py b/satire_pulp_parser/spiders/satire_pulp.py new file mode 100644 index 0000000..b285903 --- /dev/null +++ b/satire_pulp_parser/spiders/satire_pulp.py @@ -0,0 +1,28 @@ +import re + +import scrapy + + +class SatirePulpSpider(scrapy.Spider): + name = "satire_pulp" + allowed_domains = ["panorama.pub"] + start_urls = ["https://panorama.pub"] + + def parse(self, response): + self.logger.info("Главная страница Панорамы") + news_links = response.css('div.shrink-0 li a::attr(href)').getall() + for link in news_links: + full_link = response.urljoin(link) + yield scrapy.Request( + url=full_link, + callback=self.parse_news + ) + + def parse_news(self, response): + title = response.css('h1[itemprop="headline"]::text').get() + text = response.css('div.entry-contents p::text').getall() + image = response.css('meta[itemprop="image"]::attr(content)').get() + final_text = " ".join(text).strip() + self.logger.info(f'ЗАГОЛОВОК: {title}') + self.logger.info(f'ТЕКСТ: {final_text}') + self.logger.info(f'КАРТИНКА: {image}') \ No newline at end of file From 373d6b4da21987ef05613de089c2a24058854c80 Mon Sep 17 00:00:00 2001 From: Dmitry Titenkov Date: Tue, 27 Jan 2026 13:23:09 +0300 Subject: [PATCH 02/27] parser --- satire_pulp_parser/middlewares.py | 9 +---- satire_pulp_parser/pipelines.py | 9 +---- satire_pulp_parser/settings.py | 48 +++++++++++------------ satire_pulp_parser/spiders/satire_pulp.py | 19 ++++----- 4 files changed, 33 insertions(+), 52 deletions(-) diff --git a/satire_pulp_parser/middlewares.py b/satire_pulp_parser/middlewares.py index 9d0059f..5be215a 100644 --- a/satire_pulp_parser/middlewares.py +++ b/satire_pulp_parser/middlewares.py @@ -1,13 +1,6 @@ -# Define here the models for your spider middleware -# -# See documentation in: -# https://docs.scrapy.org/en/latest/topics/spider-middleware.html - +# from itemadapter import ItemAdapter from scrapy import signals -# useful for handling different item types with a single interface -from itemadapter import ItemAdapter - class SatirePulpParserSpiderMiddleware: # Not all methods need to be defined. If a method is not defined, diff --git a/satire_pulp_parser/pipelines.py b/satire_pulp_parser/pipelines.py index 64c2f02..164045c 100644 --- a/satire_pulp_parser/pipelines.py +++ b/satire_pulp_parser/pipelines.py @@ -1,11 +1,4 @@ -# Define your item pipelines here -# -# Don't forget to add your pipeline to the ITEM_PIPELINES setting -# See: https://docs.scrapy.org/en/latest/topics/item-pipeline.html - - -# useful for handling different item types with a single interface -from itemadapter import ItemAdapter +# from itemadapter import ItemAdapter class SatirePulpParserPipeline: diff --git a/satire_pulp_parser/settings.py b/satire_pulp_parser/settings.py index 6125ab5..bd1ddc2 100644 --- a/satire_pulp_parser/settings.py +++ b/satire_pulp_parser/settings.py @@ -16,72 +16,72 @@ # Crawl responsibly by identifying yourself (and your website) on the user-agent -#USER_AGENT = "satire_pulp_parser (+http://www.yourdomain.com)" +# USER_AGENT = "satire_pulp_parser (+http://www.yourdomain.com)" # Obey robots.txt rules ROBOTSTXT_OBEY = True # Concurrency and throttling settings -#CONCURRENT_REQUESTS = 16 +# CONCURRENT_REQUESTS = 16 CONCURRENT_REQUESTS_PER_DOMAIN = 1 DOWNLOAD_DELAY = 1 # Disable cookies (enabled by default) -#COOKIES_ENABLED = False +# COOKIES_ENABLED = False # Disable Telnet Console (enabled by default) -#TELNETCONSOLE_ENABLED = False +# TELNETCONSOLE_ENABLED = False # Override the default request headers: -#DEFAULT_REQUEST_HEADERS = { +# DEFAULT_REQUEST_HEADERS = { # "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", # "Accept-Language": "en", -#} +# } # Enable or disable spider middlewares # See https://docs.scrapy.org/en/latest/topics/spider-middleware.html -#SPIDER_MIDDLEWARES = { +# SPIDER_MIDDLEWARES = { # "satire_pulp_parser.middlewares.SatirePulpParserSpiderMiddleware": 543, -#} +# } # Enable or disable downloader middlewares # See https://docs.scrapy.org/en/latest/topics/downloader-middleware.html -#DOWNLOADER_MIDDLEWARES = { +# DOWNLOADER_MIDDLEWARES = { # "satire_pulp_parser.middlewares.SatirePulpParserDownloaderMiddleware": 543, -#} +# } # Enable or disable extensions # See https://docs.scrapy.org/en/latest/topics/extensions.html -#EXTENSIONS = { +# EXTENSIONS = { # "scrapy.extensions.telnet.TelnetConsole": None, -#} +# } # Configure item pipelines # See https://docs.scrapy.org/en/latest/topics/item-pipeline.html -#ITEM_PIPELINES = { +# ITEM_PIPELINES = { # "satire_pulp_parser.pipelines.SatirePulpParserPipeline": 300, -#} +# } # Enable and configure the AutoThrottle extension (disabled by default) # See https://docs.scrapy.org/en/latest/topics/autothrottle.html -#AUTOTHROTTLE_ENABLED = True +# AUTOTHROTTLE_ENABLED = True # The initial download delay -#AUTOTHROTTLE_START_DELAY = 5 +# AUTOTHROTTLE_START_DELAY = 5 # The maximum download delay to be set in case of high latencies -#AUTOTHROTTLE_MAX_DELAY = 60 +# AUTOTHROTTLE_MAX_DELAY = 60 # The average number of requests Scrapy should be sending in parallel to # each remote server -#AUTOTHROTTLE_TARGET_CONCURRENCY = 1.0 +# AUTOTHROTTLE_TARGET_CONCURRENCY = 1.0 # Enable showing throttling stats for every response received: -#AUTOTHROTTLE_DEBUG = False +# AUTOTHROTTLE_DEBUG = False # Enable and configure HTTP caching (disabled by default) # See https://docs.scrapy.org/en/latest/topics/downloader-middleware.html#httpcache-middleware-settings -#HTTPCACHE_ENABLED = True -#HTTPCACHE_EXPIRATION_SECS = 0 -#HTTPCACHE_DIR = "httpcache" -#HTTPCACHE_IGNORE_HTTP_CODES = [] -#HTTPCACHE_STORAGE = "scrapy.extensions.httpcache.FilesystemCacheStorage" +# HTTPCACHE_ENABLED = True +# HTTPCACHE_EXPIRATION_SECS = 0 +# HTTPCACHE_DIR = "httpcache" +# HTTPCACHE_IGNORE_HTTP_CODES = [] +# HTTPCACHE_STORAGE = "scrapy.extensions.httpcache.FilesystemCacheStorage" # Set settings whose default value is deprecated to a future-proof value FEED_EXPORT_ENCODING = "utf-8" diff --git a/satire_pulp_parser/spiders/satire_pulp.py b/satire_pulp_parser/spiders/satire_pulp.py index b285903..fe52b74 100644 --- a/satire_pulp_parser/spiders/satire_pulp.py +++ b/satire_pulp_parser/spiders/satire_pulp.py @@ -1,5 +1,3 @@ -import re - import scrapy @@ -10,19 +8,16 @@ class SatirePulpSpider(scrapy.Spider): def parse(self, response): self.logger.info("Главная страница Панорамы") - news_links = response.css('div.shrink-0 li a::attr(href)').getall() + news_links = response.css("div.shrink-0 li a::attr(href)").getall() for link in news_links: full_link = response.urljoin(link) - yield scrapy.Request( - url=full_link, - callback=self.parse_news - ) - + yield scrapy.Request(url=full_link, callback=self.parse_news) + def parse_news(self, response): title = response.css('h1[itemprop="headline"]::text').get() - text = response.css('div.entry-contents p::text').getall() + text = response.css("div.entry-contents p::text").getall() image = response.css('meta[itemprop="image"]::attr(content)').get() final_text = " ".join(text).strip() - self.logger.info(f'ЗАГОЛОВОК: {title}') - self.logger.info(f'ТЕКСТ: {final_text}') - self.logger.info(f'КАРТИНКА: {image}') \ No newline at end of file + self.logger.info(f"ЗАГОЛОВОК: {title}") + self.logger.info(f"ТЕКСТ: {final_text}") + self.logger.info(f"КАРТИНКА: {image}") From 1c12c7cc6abe7c7d66f20ebefa788010df957d21 Mon Sep 17 00:00:00 2001 From: Dmitry Titenkov Date: Tue, 27 Jan 2026 14:37:44 +0300 Subject: [PATCH 03/27] add sqlite3 --- .gitignore | 1 + satire_pulp.db | Bin 0 -> 24576 bytes satire_pulp_parser/news.json | 26 +++++++++++++ satire_pulp_parser/spiders/satire_pulp.py | 20 ++++++++-- scrapy.cfg | 11 ++++++ storage.py | 43 ++++++++++++++++++++++ 6 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 satire_pulp.db create mode 100644 satire_pulp_parser/news.json create mode 100644 scrapy.cfg create mode 100644 storage.py diff --git a/.gitignore b/.gitignore index b7faf40..ac2556b 100644 --- a/.gitignore +++ b/.gitignore @@ -61,6 +61,7 @@ local_settings.py db.sqlite3 db.sqlite3-journal + # Flask stuff: instance/ .webassets-cache diff --git a/satire_pulp.db b/satire_pulp.db new file mode 100644 index 0000000000000000000000000000000000000000..9392b4df83b686b66c2638b9628993b7b4aa8b15 GIT binary patch literal 24576 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 eGt9FOyV5%Jp4bRMH0=Euwi1J3M Date: Tue, 27 Jan 2026 14:39:25 +0300 Subject: [PATCH 04/27] Delete satire_pulp.db --- satire_pulp.db | Bin 24576 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 satire_pulp.db diff --git a/satire_pulp.db b/satire_pulp.db deleted file mode 100644 index 9392b4df83b686b66c2638b9628993b7b4aa8b15..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 24576 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 eGt9FOyV5%Jp4bRMH0=Euwi1J3M Date: Tue, 27 Jan 2026 14:42:14 +0300 Subject: [PATCH 05/27] add sqlite3 --- .gitignore | 13 ++++++++----- satire_pulp.db | Bin 0 -> 24576 bytes 2 files changed, 8 insertions(+), 5 deletions(-) create mode 100644 satire_pulp.db diff --git a/.gitignore b/.gitignore index ac2556b..3190fed 100644 --- a/.gitignore +++ b/.gitignore @@ -170,11 +170,8 @@ dmypy.json cython_debug/ # PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ + +.idea/ # Abstra # Abstra is an AI-powered process automation framework. @@ -206,3 +203,9 @@ cython_debug/ marimo/_static/ marimo/_lsp/ __marimo__/ + + +.DS_Store + +.db + diff --git a/satire_pulp.db b/satire_pulp.db new file mode 100644 index 0000000000000000000000000000000000000000..887b715567068f6fbd1bde9dfa68d43fdf2003ce GIT binary patch literal 24576 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 Date: Tue, 27 Jan 2026 14:46:30 +0300 Subject: [PATCH 06/27] Delete satire_pulp.db --- satire_pulp.db | Bin 24576 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 satire_pulp.db diff --git a/satire_pulp.db b/satire_pulp.db deleted file mode 100644 index 887b715567068f6fbd1bde9dfa68d43fdf2003ce..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 24576 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 Date: Tue, 27 Jan 2026 14:47:15 +0300 Subject: [PATCH 07/27] add db to gitignore --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 3190fed..be5b05c 100644 --- a/.gitignore +++ b/.gitignore @@ -207,5 +207,5 @@ __marimo__/ .DS_Store -.db +*.db From 664e339020f6dbdff252945747dfb2263d3fb144 Mon Sep 17 00:00:00 2001 From: Dmitry Titenkov Date: Thu, 29 Jan 2026 15:24:56 +0300 Subject: [PATCH 08/27] bot --- .gitignore | 1 - bot.py | 158 ++++++++++++++++++++++ config.py | 25 ++++ requirements.txt | 5 + satire_pulp_parser/spiders/satire_pulp.py | 12 +- storage.py | 30 +++- 6 files changed, 224 insertions(+), 7 deletions(-) create mode 100644 bot.py create mode 100644 config.py diff --git a/.gitignore b/.gitignore index be5b05c..76c65b5 100644 --- a/.gitignore +++ b/.gitignore @@ -208,4 +208,3 @@ __marimo__/ .DS_Store *.db - diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..990d8ff --- /dev/null +++ b/bot.py @@ -0,0 +1,158 @@ +import asyncio +import logging +import os + +from config import setup_logger +from dotenv import load_dotenv +from storage import get_last_news +from telegram import ( + BotCommand, + InlineKeyboardButton, + InlineKeyboardMarkup, + Update, +) +from telegram.ext import ( + ApplicationBuilder, + CallbackQueryHandler, + CommandHandler, + ContextTypes, +) + +load_dotenv() + +setup_logger() +logger = logging.getLogger(__name__) + + +MAX_CAPTION_LENGTH = 1024 + + +def format_message(title, text, url): + message = f"*{title}*\n\n{text}\n" + if len(message) > MAX_CAPTION_LENGTH: + message = f"*{title}*\n\n{text[:MAX_CAPTION_LENGTH]} ...✂️\n" + return message + + +async def send_news( + chat_id: int, context: ContextTypes.DEFAULT_TYPE, title, image, text, url +): + message = format_message(title, text, url) + keyboard = [ + [InlineKeyboardButton("Читать полную версию на сайте", url=url)] + ] + reply_markup = InlineKeyboardMarkup(keyboard) + if image: + try: + await context.bot.send_photo( + chat_id=chat_id, + photo=image, + caption=message, + parse_mode="Markdown", + reply_markup=reply_markup, + ) + logger.info("Новость отправлена с картинкой") + return + except Exception as e: + logger.error( + f"Не удалось отправить фото по ссылке, ошибка: {e}", + ) + try: + await context.bot.send_message( + chat_id, message, parse_mode="Markdown", reply_markup=reply_markup + ) + logger.info(f"Новость '{title[:25]}' отправлена без картинки") + except Exception as e: + logger.error( + f"Не удалось отправить сообщение с новостью '{title[:25]}', ошибка: {e}" + ) + + +async def menu(update: Update, context: ContextTypes.DEFAULT_TYPE): + keyboard = [ + [ + InlineKeyboardButton( + "📰 Показать новости", callback_data="send_news" + ) + ], + [InlineKeyboardButton("ℹ️ Помощь", callback_data="help")], + ] + reply_markup = InlineKeyboardMarkup(keyboard) + await update.message.reply_text( + "Выберите действие:", reply_markup=reply_markup + ) + + +async def button_handler(update: Update, context: ContextTypes.DEFAULT_TYPE): + query = update.callback_query + try: + await query.answer() + except Exception as e: + logger.warning(f"callback_query не удалось ответить: {e}") + if query.data == "send_news": + + news_list = get_last_news(10) + if not news_list: + await query.message.reply_text("Новостей пока нет 🙁") + logger.info("Новостей нет") + return + try: + for title, image, text, url in news_list: + await send_news( + query.message.chat_id, context, title, image, text, url + ) + except Exception as e: + logger.error(f"Ошибка при отправке новости '{title[:25]}': {e}") + elif query.data == "help": + help_text = "Нажмите 📰 'Показать новости', чтобы получить новости." + await query.message.reply_text(help_text) + + +async def show_news_command( + update: Update, context: ContextTypes.DEFAULT_TYPE +): + news_list = get_last_news(5) + if not news_list: + await update.message.reply_text("Новостей пока нет 🙁") + logger.info("Новостей нет") + return + try: + for title, image, text, url in news_list: + await send_news( + update.message.chat_id, context, title, image, text, url + ) + except Exception as e: + logger.error(f"Ошибка при отправке новости '{title[:25]}': {e}") + + +async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + help_text = "Нажмите 📰 'Показать новости', чтобы получить новости." + await update.message.reply_text(help_text) + + +async def set_commands(app): + commands = [ + BotCommand("start", "Главное меню"), + BotCommand("show_news", "Показать новости"), + BotCommand("help", "Помощь"), + ] + await app.bot.set_my_commands(commands) + + +def main(): + app = ApplicationBuilder().token(os.getenv("TELEGRAM_TOKEN")).build() + + app.add_handler(CommandHandler("start", menu)) + app.add_handler(CommandHandler("show_news", show_news_command)) + app.add_handler(CommandHandler("help", help_command)) + app.add_handler(CallbackQueryHandler(button_handler)) + + asyncio.get_event_loop().run_until_complete(set_commands(app)) + + logger.info("... Бот запущен ...") + app.run_polling() + logger.info("... Бот остановлен ...") + + +if __name__ == "__main__": + main() diff --git a/config.py b/config.py new file mode 100644 index 0000000..0a1746b --- /dev/null +++ b/config.py @@ -0,0 +1,25 @@ +import logging +from logging.handlers import RotatingFileHandler +from pathlib import Path + +LOG_DIR = Path("logs") +LOG_DIR.mkdir(exist_ok=True) + +LOG_FILE = LOG_DIR / "app.log" + + +def setup_logger(): + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s: - |%(levelname)s| %(name)s | %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + handlers=[ + logging.StreamHandler(), + RotatingFileHandler( + filename=LOG_FILE, + maxBytes=1024 * 1024 * 5, + backupCount=5, + encoding="utf-8", + ), + ], + ) diff --git a/requirements.txt b/requirements.txt index 7345173..eebfbbd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +anyio==4.12.1 attrs==25.4.0 Automat==25.4.16 black==26.1.0 @@ -11,6 +12,9 @@ cssselect==1.3.0 defusedxml==0.7.1 filelock==3.20.3 flake8==7.3.0 +h11==0.16.0 +httpcore==1.0.9 +httpx==0.28.1 hyperlink==21.0.0 idna==3.11 Incremental==24.11.0 @@ -38,6 +42,7 @@ Pygments==2.19.2 pyOpenSSL==25.3.0 pytest==9.0.2 python-dotenv==1.2.1 +python-telegram-bot==22.6 pytokens==0.4.0 queuelib==1.8.0 requests==2.32.5 diff --git a/satire_pulp_parser/spiders/satire_pulp.py b/satire_pulp_parser/spiders/satire_pulp.py index faaad57..435170e 100644 --- a/satire_pulp_parser/spiders/satire_pulp.py +++ b/satire_pulp_parser/spiders/satire_pulp.py @@ -23,10 +23,16 @@ def parse(self, response): def parse_news(self, response): title = response.css('h1[itemprop="headline"]::text').get() - text = response.css("div.entry-contents p::text").getall() + text = response.css("div.entry-contents p::text").get() image = response.css('meta[itemprop="image"]::attr(content)').get() - final_text = " ".join(text).strip() - save_news(response.url, title) + final_title = title.strip() + final_text = text.strip() + if image: + image = response.urljoin(image).strip() + + else: + image = None + save_news(response.url, final_title, image, final_text) yield { "title": title, "text": final_text, diff --git a/storage.py b/storage.py index 09642a9..5e544d5 100644 --- a/storage.py +++ b/storage.py @@ -15,6 +15,8 @@ def init_db(): id INTEGER PRIMARY KEY AUTOINCREMENT, url TEXT UNIQUE, title TEXT, + image TEXT, + text TEXT, create_at TEXT ) """) @@ -32,12 +34,34 @@ def is_news_exists(url): return cursor.fetchone() is not None -def save_news(url, title): +def save_news(url, title, image, text): with get_connecttion() as conn: conn.execute( """ - INSERT INTO news (url, title, create_at) VALUES (?, ?, ?) + INSERT INTO news (url, title, image, text, create_at) VALUES (?, ?, ?, ?, ?) """, - (url, title, datetime.now(timezone.utc).isoformat()), + (url, title, image, text, datetime.now(timezone.utc).isoformat()), ) conn.commit() + + +def get_last_news(limit=5): + with get_connecttion() as conn: + curcor = conn.execute( + """ + SELECT title, image, text, url FROM news ORDER BY id DESC LIMIT ? +""", + (limit,), + ) + return curcor.fetchall() + + +def get_news_id(last_id): + with get_connecttion() as conn: + cursor = conn.execute( + """ + SELECT id, title, text, image, url FORM news WHERE id > ? +""", + (last_id,), + ) + return cursor.fetchall() From db0a1c7e9bd8cfdde5df6f92fb7127fc18bfd660 Mon Sep 17 00:00:00 2001 From: Dmitry Titenkov Date: Thu, 5 Feb 2026 15:28:22 +0300 Subject: [PATCH 09/27] =?UTF-8?q?=D0=B1=D0=BE=D1=82=20=D0=B0=D0=B2=D1=82?= =?UTF-8?q?=D0=BE=D0=BC=D0=B0=D1=82=D0=B8=D1=87=D0=B5=D1=81=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=BE=D1=82=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D1=8F=D0=B5=D1=82=20?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D0=BE=D1=81=D1=82=D1=8C,=20=D0=B5=D1=81?= =?UTF-8?q?=D0=BB=D0=B8=20=D0=BD=D0=BE=D0=B2=D0=B0=D1=8F=20=D0=BF=D0=BE?= =?UTF-8?q?=D1=8F=D0=B2=D0=B8=D0=BB=D0=B0=D1=81=D1=8C=20=D0=B2=20=D0=B1?= =?UTF-8?q?=D0=B0=D0=B7=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot.py | 84 ++++++++++++++++++++++++++++++----------------------- last_id.txt | 1 + main.py | 44 ++++++++++++++++++++++++++++ storage.py | 15 ++-------- 4 files changed, 94 insertions(+), 50 deletions(-) create mode 100644 last_id.txt create mode 100644 main.py diff --git a/bot.py b/bot.py index 990d8ff..195da89 100644 --- a/bot.py +++ b/bot.py @@ -1,22 +1,16 @@ -import asyncio import logging import os from config import setup_logger from dotenv import load_dotenv -from storage import get_last_news +from storage import get_news_after_id from telegram import ( BotCommand, InlineKeyboardButton, InlineKeyboardMarkup, Update, ) -from telegram.ext import ( - ApplicationBuilder, - CallbackQueryHandler, - CommandHandler, - ContextTypes, -) +from telegram.ext import ContextTypes load_dotenv() @@ -25,19 +19,34 @@ MAX_CAPTION_LENGTH = 1024 +LAST_ID_FILE = "last_id.txt" -def format_message(title, text, url): +def format_message(title, text): message = f"*{title}*\n\n{text}\n" if len(message) > MAX_CAPTION_LENGTH: message = f"*{title}*\n\n{text[:MAX_CAPTION_LENGTH]} ...✂️\n" return message +def get_last_sent_id(): + """Получение id последней новости""" + if not os.path.exists(LAST_ID_FILE): + return 0 + with open(LAST_ID_FILE, "r") as f: + return int(f.read()) + + +def save_last_sent_id(last_id): + """Сохранение id последней новости""" + with open(LAST_ID_FILE, "w") as f: + f.write(str(last_id)) + + async def send_news( chat_id: int, context: ContextTypes.DEFAULT_TYPE, title, image, text, url ): - message = format_message(title, text, url) + message = format_message(title, text) keyboard = [ [InlineKeyboardButton("Читать полную версию на сайте", url=url)] ] @@ -68,6 +77,20 @@ async def send_news( ) +async def auto_send_news(context: ContextTypes.DEFAULT_TYPE): + chat_id = os.getenv("TELEGRAM_CHAT_ID") + last_id = get_last_sent_id() + news_list = get_news_after_id(last_id) + if not news_list: + return + for news_id, title, image, text, url in news_list: + try: + await send_news(chat_id, context, title, image, text, url) + save_last_sent_id(news_id) + except Exception as e: + logger.error(f"Ошибка при автоматической отправке новости: {e}") + + async def menu(update: Update, context: ContextTypes.DEFAULT_TYPE): keyboard = [ [ @@ -91,36 +114,42 @@ async def button_handler(update: Update, context: ContextTypes.DEFAULT_TYPE): logger.warning(f"callback_query не удалось ответить: {e}") if query.data == "send_news": - news_list = get_last_news(10) + last_id = get_last_sent_id() + news_list = get_news_after_id(last_id) if not news_list: await query.message.reply_text("Новостей пока нет 🙁") logger.info("Новостей нет") return - try: - for title, image, text, url in news_list: + for news_id, title, image, text, url in news_list: + try: await send_news( query.message.chat_id, context, title, image, text, url ) - except Exception as e: - logger.error(f"Ошибка при отправке новости '{title[:25]}': {e}") + save_last_sent_id(news_id) + except Exception as e: + logger.error( + f"Ошибка при отправке новости '{title[:25]}': {e}" + ) elif query.data == "help": - help_text = "Нажмите 📰 'Показать новости', чтобы получить новости." + help_text = "Нажмите 📰 'Показать blaновости', чтобы получить новости." await query.message.reply_text(help_text) async def show_news_command( update: Update, context: ContextTypes.DEFAULT_TYPE ): - news_list = get_last_news(5) + last_id = get_last_sent_id() + news_list = get_news_after_id(last_id) if not news_list: await update.message.reply_text("Новостей пока нет 🙁") logger.info("Новостей нет") return try: - for title, image, text, url in news_list: + for news_id, title, image, text, url in news_list: await send_news( update.message.chat_id, context, title, image, text, url ) + save_last_sent_id(news_id) except Exception as e: logger.error(f"Ошибка при отправке новости '{title[:25]}': {e}") @@ -137,22 +166,3 @@ async def set_commands(app): BotCommand("help", "Помощь"), ] await app.bot.set_my_commands(commands) - - -def main(): - app = ApplicationBuilder().token(os.getenv("TELEGRAM_TOKEN")).build() - - app.add_handler(CommandHandler("start", menu)) - app.add_handler(CommandHandler("show_news", show_news_command)) - app.add_handler(CommandHandler("help", help_command)) - app.add_handler(CallbackQueryHandler(button_handler)) - - asyncio.get_event_loop().run_until_complete(set_commands(app)) - - logger.info("... Бот запущен ...") - app.run_polling() - logger.info("... Бот остановлен ...") - - -if __name__ == "__main__": - main() diff --git a/last_id.txt b/last_id.txt new file mode 100644 index 0000000..6139554 --- /dev/null +++ b/last_id.txt @@ -0,0 +1 @@ +52 \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..e245f31 --- /dev/null +++ b/main.py @@ -0,0 +1,44 @@ +import asyncio +import logging +import os + +from bot import ( + auto_send_news, + button_handler, + help_command, + menu, + set_commands, + show_news_command, +) +from config import setup_logger +from dotenv import load_dotenv +from telegram.ext import ( + ApplicationBuilder, + CallbackQueryHandler, + CommandHandler, +) + +load_dotenv() + +setup_logger() +logger = logging.getLogger(__name__) + + +def main(): + app = ApplicationBuilder().token(os.getenv("TELEGRAM_TOKEN")).build() + app.job_queue.run_repeating(auto_send_news, interval=10, first=10) + + app.add_handler(CommandHandler("start", menu)) + app.add_handler(CommandHandler("show_news", show_news_command)) + app.add_handler(CommandHandler("help", help_command)) + app.add_handler(CallbackQueryHandler(button_handler)) + + asyncio.get_event_loop().run_until_complete(set_commands(app)) + + logger.info("... Бот запущен ...") + app.run_polling() + logger.info("... Бот остановлен ...") + + +if __name__ == "__main__": + main() diff --git a/storage.py b/storage.py index 5e544d5..04976c0 100644 --- a/storage.py +++ b/storage.py @@ -45,22 +45,11 @@ def save_news(url, title, image, text): conn.commit() -def get_last_news(limit=5): - with get_connecttion() as conn: - curcor = conn.execute( - """ - SELECT title, image, text, url FROM news ORDER BY id DESC LIMIT ? -""", - (limit,), - ) - return curcor.fetchall() - - -def get_news_id(last_id): +def get_news_after_id(last_id): with get_connecttion() as conn: cursor = conn.execute( """ - SELECT id, title, text, image, url FORM news WHERE id > ? + SELECT id, title, image, text, url FROM news WHERE id > ? ORDER BY id ASC """, (last_id,), ) From a25fa7c91bdee0f4d1fd62067ad23493442d14a4 Mon Sep 17 00:00:00 2001 From: Dmitry Titenkov Date: Thu, 5 Feb 2026 15:30:11 +0300 Subject: [PATCH 10/27] requirements.txt --- requirements.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/requirements.txt b/requirements.txt index eebfbbd..4a91524 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ anyio==4.12.1 +APScheduler==3.11.2 attrs==25.4.0 Automat==25.4.16 black==26.1.0 @@ -52,6 +53,7 @@ service-identity==24.2.0 tldextract==5.3.1 Twisted==25.5.0 typing_extensions==4.15.0 +tzlocal==5.3.1 urllib3==2.6.3 w3lib==2.3.1 zope.interface==8.2 From 209f4aa286f86f1540b0decbdc98402813f27cac Mon Sep 17 00:00:00 2001 From: Dmitry Titenkov Date: Thu, 5 Feb 2026 16:15:04 +0300 Subject: [PATCH 11/27] Delete last_id.txt --- last_id.txt | 1 - 1 file changed, 1 deletion(-) delete mode 100644 last_id.txt diff --git a/last_id.txt b/last_id.txt deleted file mode 100644 index 6139554..0000000 --- a/last_id.txt +++ /dev/null @@ -1 +0,0 @@ -52 \ No newline at end of file From ffa649575258b320734ad3687dbdd1e687c02319 Mon Sep 17 00:00:00 2001 From: Dmitry Titenkov Date: Thu, 5 Feb 2026 16:15:39 +0300 Subject: [PATCH 12/27] Update .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 76c65b5..a2004c9 100644 --- a/.gitignore +++ b/.gitignore @@ -208,3 +208,4 @@ __marimo__/ .DS_Store *.db +last_id.txt From 865dca4127d0a455fe09216787015ff94623fcbf Mon Sep 17 00:00:00 2001 From: Dmitry Titenkov Date: Sat, 7 Feb 2026 12:17:03 +0300 Subject: [PATCH 13/27] scheduler --- main.py | 3 +-- requirements.txt | 1 + scheduler.py | 55 ++++++++++++++++++++++++++++++++++++++++++++++++ storage.py | 10 ++++----- 4 files changed, 62 insertions(+), 7 deletions(-) create mode 100644 scheduler.py diff --git a/main.py b/main.py index e245f31..1ef499f 100644 --- a/main.py +++ b/main.py @@ -18,6 +18,7 @@ CommandHandler, ) + load_dotenv() setup_logger() @@ -27,14 +28,12 @@ def main(): app = ApplicationBuilder().token(os.getenv("TELEGRAM_TOKEN")).build() app.job_queue.run_repeating(auto_send_news, interval=10, first=10) - app.add_handler(CommandHandler("start", menu)) app.add_handler(CommandHandler("show_news", show_news_command)) app.add_handler(CommandHandler("help", help_command)) app.add_handler(CallbackQueryHandler(button_handler)) asyncio.get_event_loop().run_until_complete(set_commands(app)) - logger.info("... Бот запущен ...") app.run_polling() logger.info("... Бот остановлен ...") diff --git a/requirements.txt b/requirements.txt index 4a91524..ab8e9a0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -48,6 +48,7 @@ pytokens==0.4.0 queuelib==1.8.0 requests==2.32.5 requests-file==3.0.1 +schedule==1.2.2 Scrapy==2.14.1 service-identity==24.2.0 tldextract==5.3.1 diff --git a/scheduler.py b/scheduler.py new file mode 100644 index 0000000..9f84d44 --- /dev/null +++ b/scheduler.py @@ -0,0 +1,55 @@ +import logging +import subprocess + +from apscheduler.schedulers.blocking import BlockingScheduler + +from config import setup_logger + + +setup_logger() +logger = logging.getLogger(__name__) + + +def run_spider(): + """Запуск паука""" + try: + logger.info("...Запуск парсера...") + result = subprocess.run( + ["scrapy", "crawl", "satire_pulp"], + capture_output=True, + text=True, + ) + if result.returncode == 0: + logger.info("...Парсер успешно завершил работу...") + else: + logger.error(f"Ошибка при запуске парсера: {result.stderr}") + except Exception as e: + logger.error(f"Неожиданная ошибка при запуске парсера: {e}") + + +def run_bot(): + app = ApplicationBuilder().token(os.getenv("TELEGRAM_TOKEN")).build() + app.job_queue.run_repeating(auto_send_news, interval=10, first=10) + app.add_handler(CommandHandler("start", menu)) + app.add_handler(CommandHandler("show_news", show_news_command)) + app.add_handler(CommandHandler("help", help_command)) + app.add_handler(CallbackQueryHandler(button_handler)) + + asyncio.get_event_loop().run_until_complete(set_commands(app)) + logger.info("... Бот запущен ...") + app.run_polling() + logger.info("... Бот остановлен ...") + + +def run_scheduler(): + scheduler = BlockingScheduler() + try: + logger.info("...Планировщик запущен...") + run_spider() + scheduler.add_job(run_spider, "interval", minutes=1) + scheduler.start() + except Exception as e: + logger.error(f"Ошибкка в планировщике: {e}") + + +run_scheduler() diff --git a/storage.py b/storage.py index 04976c0..0c315f7 100644 --- a/storage.py +++ b/storage.py @@ -4,12 +4,12 @@ DB_NAME = "satire_pulp.db" -def get_connecttion(): +def get_connection(): return sqlite3.connect(DB_NAME) def init_db(): - with get_connecttion() as conn: + with get_connection() as conn: conn.execute(""" CREATE TABLE IF NOT EXISTS news ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -24,7 +24,7 @@ def init_db(): def is_news_exists(url): - with get_connecttion() as conn: + with get_connection() as conn: cursor = conn.execute( """ SELECT 1 FROM news WHERE url = ? @@ -35,7 +35,7 @@ def is_news_exists(url): def save_news(url, title, image, text): - with get_connecttion() as conn: + with get_connection() as conn: conn.execute( """ INSERT INTO news (url, title, image, text, create_at) VALUES (?, ?, ?, ?, ?) @@ -46,7 +46,7 @@ def save_news(url, title, image, text): def get_news_after_id(last_id): - with get_connecttion() as conn: + with get_connection() as conn: cursor = conn.execute( """ SELECT id, title, image, text, url FROM news WHERE id > ? ORDER BY id ASC From f496e0b3f8950057cf685c53c2e7aa529a49d03c Mon Sep 17 00:00:00 2001 From: Dmitry Titenkov Date: Sat, 7 Feb 2026 12:18:08 +0300 Subject: [PATCH 14/27] scheduler --- scheduler.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/scheduler.py b/scheduler.py index 9f84d44..8e9a0f0 100644 --- a/scheduler.py +++ b/scheduler.py @@ -27,20 +27,6 @@ def run_spider(): logger.error(f"Неожиданная ошибка при запуске парсера: {e}") -def run_bot(): - app = ApplicationBuilder().token(os.getenv("TELEGRAM_TOKEN")).build() - app.job_queue.run_repeating(auto_send_news, interval=10, first=10) - app.add_handler(CommandHandler("start", menu)) - app.add_handler(CommandHandler("show_news", show_news_command)) - app.add_handler(CommandHandler("help", help_command)) - app.add_handler(CallbackQueryHandler(button_handler)) - - asyncio.get_event_loop().run_until_complete(set_commands(app)) - logger.info("... Бот запущен ...") - app.run_polling() - logger.info("... Бот остановлен ...") - - def run_scheduler(): scheduler = BlockingScheduler() try: From b6425f6b8e62bc369c8b8c5eec079ca0d56a80df Mon Sep 17 00:00:00 2001 From: Dmitry Titenkov Date: Sat, 7 Feb 2026 12:21:21 +0300 Subject: [PATCH 15/27] scheduler --- main.py | 1 - scheduler.py | 2 -- 2 files changed, 3 deletions(-) diff --git a/main.py b/main.py index 1ef499f..a0d4119 100644 --- a/main.py +++ b/main.py @@ -18,7 +18,6 @@ CommandHandler, ) - load_dotenv() setup_logger() diff --git a/scheduler.py b/scheduler.py index 8e9a0f0..4642e33 100644 --- a/scheduler.py +++ b/scheduler.py @@ -2,10 +2,8 @@ import subprocess from apscheduler.schedulers.blocking import BlockingScheduler - from config import setup_logger - setup_logger() logger = logging.getLogger(__name__) From d9c5749aed8e8a9237e0ffa439ae3d9faf0945d7 Mon Sep 17 00:00:00 2001 From: Dmitry Titenkov Date: Sat, 7 Feb 2026 15:49:44 +0300 Subject: [PATCH 16/27] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D0=BD=D0=BE=D0=B2=D0=B0=D1=8F=20=D1=82?= =?UTF-8?q?=D0=B0=D0=B1=D0=BB=D0=B8=D1=86=D0=B2=20=D0=B2=20=D0=B1=D0=B0?= =?UTF-8?q?=D0=B7=D1=83,=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D0=B0=D0=BD=D0=B0=20=D0=B0=D0=B2=D1=82=D0=BE=D0=BE=D1=82=D0=BF?= =?UTF-8?q?=D1=80=D0=B0=D0=B2=D0=BA=D0=B0=20=D1=81=D0=BE=D0=BE=D0=B1=D1=89?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .flake8 | 2 +- bot.py | 82 ++++++++++++++++++------------------ main.py | 4 +- satire_pulp_parser/news.json | 26 ------------ scheduler.py | 2 +- storage.py | 48 +++++++++++++++++++-- 6 files changed, 89 insertions(+), 75 deletions(-) delete mode 100644 satire_pulp_parser/news.json diff --git a/.flake8 b/.flake8 index a4ca38c..bb835ee 100644 --- a/.flake8 +++ b/.flake8 @@ -1,6 +1,6 @@ [flake8] max-line-length = 79 -extend-ignore = E501, W503, E203, E402, E712 +extend-ignore = E501, W503, E203, E402, E712, W605 exclude = .git, backend/alembic/versions/*, diff --git a/bot.py b/bot.py index 195da89..0c396eb 100644 --- a/bot.py +++ b/bot.py @@ -1,9 +1,13 @@ import logging -import os from config import setup_logger from dotenv import load_dotenv -from storage import get_news_after_id +from storage import ( + get_all_users, + get_last_sent_id, + get_news_after_id, + save_last_sent_news_id, +) from telegram import ( BotCommand, InlineKeyboardButton, @@ -19,7 +23,6 @@ MAX_CAPTION_LENGTH = 1024 -LAST_ID_FILE = "last_id.txt" def format_message(title, text): @@ -29,20 +32,6 @@ def format_message(title, text): return message -def get_last_sent_id(): - """Получение id последней новости""" - if not os.path.exists(LAST_ID_FILE): - return 0 - with open(LAST_ID_FILE, "r") as f: - return int(f.read()) - - -def save_last_sent_id(last_id): - """Сохранение id последней новости""" - with open(LAST_ID_FILE, "w") as f: - f.write(str(last_id)) - - async def send_news( chat_id: int, context: ContextTypes.DEFAULT_TYPE, title, image, text, url ): @@ -78,20 +67,32 @@ async def send_news( async def auto_send_news(context: ContextTypes.DEFAULT_TYPE): - chat_id = os.getenv("TELEGRAM_CHAT_ID") - last_id = get_last_sent_id() - news_list = get_news_after_id(last_id) - if not news_list: + users = get_all_users() + if not users: return - for news_id, title, image, text, url in news_list: - try: - await send_news(chat_id, context, title, image, text, url) - save_last_sent_id(news_id) - except Exception as e: - logger.error(f"Ошибка при автоматической отправке новости: {e}") + for chat_id in users: + last_id = get_last_sent_id(chat_id) + news_list = get_news_after_id(last_id) + if not news_list: + return + for news_id, title, image, text, url in news_list: + try: + await send_news(chat_id, context, title, image, text, url) + save_last_sent_news_id(chat_id, news_id) + except Exception as e: + logger.error( + f"Ошибка при автоматической отправке новости: {e}" + ) async def menu(update: Update, context: ContextTypes.DEFAULT_TYPE): + # chat_id = update.message.chat_id + welcome_text = ( + "Привеет\!\n" + "Я бот, который присылает сатирические новости с сайта [*Панорама*](https://panorama.pub)\.\n\n" + "Нажмите 📰 '*Показать новости*', чтобы получить новости\.\n\n" + "Если на сайте появится новая новость \- я пришлю её\." + ) keyboard = [ [ InlineKeyboardButton( @@ -102,7 +103,7 @@ async def menu(update: Update, context: ContextTypes.DEFAULT_TYPE): ] reply_markup = InlineKeyboardMarkup(keyboard) await update.message.reply_text( - "Выберите действие:", reply_markup=reply_markup + welcome_text, reply_markup=reply_markup, parse_mode="MarkdownV2" ) @@ -113,32 +114,31 @@ async def button_handler(update: Update, context: ContextTypes.DEFAULT_TYPE): except Exception as e: logger.warning(f"callback_query не удалось ответить: {e}") if query.data == "send_news": - - last_id = get_last_sent_id() + chat_id = query.message.chat_id + last_id = get_last_sent_id(chat_id) news_list = get_news_after_id(last_id) if not news_list: - await query.message.reply_text("Новостей пока нет 🙁") - logger.info("Новостей нет") + await query.message.reply_text("Новых новостей пока нет 🙁") + logger.info("Новых новостей нет") return for news_id, title, image, text, url in news_list: try: - await send_news( - query.message.chat_id, context, title, image, text, url - ) - save_last_sent_id(news_id) + await send_news(chat_id, context, title, image, text, url) + save_last_sent_news_id(chat_id, news_id) except Exception as e: logger.error( f"Ошибка при отправке новости '{title[:25]}': {e}" ) elif query.data == "help": - help_text = "Нажмите 📰 'Показать blaновости', чтобы получить новости." + help_text = "Нажмите 📰 'Показать новости', чтобы получить новости." await query.message.reply_text(help_text) async def show_news_command( update: Update, context: ContextTypes.DEFAULT_TYPE ): - last_id = get_last_sent_id() + chat_id = update.message.chat_id + last_id = get_last_sent_id(chat_id) news_list = get_news_after_id(last_id) if not news_list: await update.message.reply_text("Новостей пока нет 🙁") @@ -146,10 +146,8 @@ async def show_news_command( return try: for news_id, title, image, text, url in news_list: - await send_news( - update.message.chat_id, context, title, image, text, url - ) - save_last_sent_id(news_id) + await send_news(chat_id, context, title, image, text, url) + save_last_sent_news_id(chat_id, news_id) except Exception as e: logger.error(f"Ошибка при отправке новости '{title[:25]}': {e}") diff --git a/main.py b/main.py index a0d4119..e39e29f 100644 --- a/main.py +++ b/main.py @@ -12,6 +12,7 @@ ) from config import setup_logger from dotenv import load_dotenv +from storage import init_db from telegram.ext import ( ApplicationBuilder, CallbackQueryHandler, @@ -25,8 +26,9 @@ def main(): + init_db() app = ApplicationBuilder().token(os.getenv("TELEGRAM_TOKEN")).build() - app.job_queue.run_repeating(auto_send_news, interval=10, first=10) + app.job_queue.run_repeating(auto_send_news, interval=600, first=10) app.add_handler(CommandHandler("start", menu)) app.add_handler(CommandHandler("show_news", show_news_command)) app.add_handler(CommandHandler("help", help_command)) diff --git a/satire_pulp_parser/news.json b/satire_pulp_parser/news.json deleted file mode 100644 index 87033ad..0000000 --- a/satire_pulp_parser/news.json +++ /dev/null @@ -1,26 +0,0 @@ -[ -{"title": "\n Губернатор Беглов лично конфисковал петербуржскую квартиру Ларисы Долиной ", "text": "Губернатор Александр Беглов в рамках личной инспекции изъял недвижимость у народной артистки РФ Ларисы Долиной. Решение было принято на месте в связи с обнаруженными нарушениями. В ходе планового объезда исторического центра города глава региона обратил внимание на фасад дома, где располагаются апартаменты певицы. Его насторожил неверный, по мнению градоначальника, оттенок занавесок на окнах, диссонирующий с палитрой карниза. Не дожидаясь заключения комиссии, губернатор вынес вердикт о конфискации жилплощади в пользу города. Юридическим основанием послужила внутренняя инструкция о сохранении визуального облика парадных подъездов. Теперь в квартире будет размещён филиал музея истории оконных декоров Санкт-Петербурга. «Это не самоуправство, это высокая ответственность перед городом-музеем, – прокомментировал Беглов. – Каждый элемент, будь то лепнина, дверная ручка или гардина, должен соответствовать духу Северной столицы. Гардины госпожи Долиной, увы, пели не в той тональности. Я, как дирижёр городского оркестра фасадов, не мог этого допустить. Певица понимает ситуацию, она получила в замен сертификат на пожизненное право слушать «Петербургские серенады» из-под моста Бетанкура».", "image": "https://panorama.pub/storage/images/04/9a/34e485d5dc63b5d4bc846ed5cb3c/previews/37274-mw-1600.jpg.webp", "url": "https://panorama.pub/news/dolina-beglov-licno-konfiskoval-peterburzskuu"}, -{"title": "\n Верховный суд запретил международное движение ", "text": "Верховный суд принял решение о внесении организации «Международное движение» в реестр экстремистских. Соответствующий иск подал Минюст ещё в конце 2025 года, обосновывая его многочисленными экспертизами, обращениями общественных организаций и правоохранительных органов. Эксперты полагают, что «Международное движение» ставит своей целью вовлечение в свои ряды россиян, в особенности молодого поколения, и формирование у них определённых взглядов. «Речь идёт об организации, которая имеет чрезвычайно высокую распространённость – её филиалы или отдельные агенты присутствуют во всех без исключения странах мира», – говорится в исковых материалах. Несмотря на то, что конкретные цели «Международного движения» определить пока не удалось, суд согласился с доводами о том, что любая организация, деятельность которой не поддаётся полному и безоговорочному контролю со стороны органов государственной власти Российской Федерации, представляет угрозу для морально-нравственного развития несовершеннолетних и традиционных ценностей, а следовательно должна быть запрещена из соображений заботы о детях. Ранее на подобных основаниях в России уже запретили деятельность агентства ООН по защите архитектурных памятников древнейшей истории, Всемирной туристской организации и датской компании Lego.", "image": "https://panorama.pub/storage/images/b3/f1/cf204b356ea2d48dd4bad7f5ac3a/previews/37279-mw-1600.jpg.webp", "url": "https://panorama.pub/news/verhovnyj-sud-zapretil-mezdunarodnoe-dvizenie"}, -{"title": "\n Ким Чен Ын: «Гренландия — это исторический регион Кореи, но мы готовы продать её Трампу» ", "text": "Глава КНДР Ким Чен Ын направил предложение Дональду Трампу приобрести Гренландию, которая «была открыта и освоена корейцами», но сейчас временно находится под оккупацией Дании. «Мы никогда не забывали, что Гренландию открыл великий Ли Сунсин, что первыми поселенцами там были именно наши люди. Проклятые датские империалисты незаконной захватили этот исконно корейский остров, но мы готовы его вернуть и продать Трампу», – сказал лидер КНДР. Пхеньян предлагает Вашингтону обменять самый большой на Земле остров на снятие всех санкций и 300 миллиардов долларов. Если США примут это предложение, то Ким Чен Ын планирует сначала предъявить Дании ультиматум с требованием вернуть Гренландию, а в случае отказа готов нанести ядерный удар по Копенгагену. По информации Bloomberg, Трамп «пришёл в восторг» от инициативы главы Северной Кореи и даже готов простить ему, если «одна из столиц на севере Европы превратится в радиоактивный пепел». Однако официально Белый дом пока воздержался от комментариев.", "image": "https://panorama.pub/storage/images/99/8d/14a1b75d4773090bffbeed16e51f/previews/37240-mw-1600.jpg.webp", "url": "https://panorama.pub/news/kim-cen-yn-grenlandia-"}, -{"title": "\n Губернатор Московской области освободил пекарню «Машенька» от уплаты всех налогов до 2050 года ", "text": "Губернатор Московской области Андрей Воробьёв подписал указ о введении особого налогового режима для пекарни «Машенька», которая находится в Люберцах. Согласно документу, предприятие до 2050 года полностью освобождается от уплаты всех налогов, в том числе страховых взносов. По замыслу подмосковных чиновников, это позволит «Машеньке» преодолеть временные трудности. «Товарищ Максимов, которому принадлежит эта пекарня, оказался в сложной ситуации, поэтому мы приняли решение помочь ему. Уверен, что теперь он быстро выйдет на прибыль и будет создавать новые рабочие места», – сказал замминистра труда области Матвей Иванов. Сам предприниматель поблагодарил губернатора за помощь и пообещал в ближайшее время представить план развития «Машеньки» – бизнесмен планирует открыть пекарни во всех регионах России в ближайшие пять лет.", "image": "https://panorama.pub/storage/images/f3/86/2b0a3b19bc9e490323d675467b5f/previews/37280-mw-1600.jpg.webp", "url": "https://panorama.pub/news/gubernator-moskovskoj-oblasti-osvobodil-pekarnu"}, -{"title": "\n Технология замедления старения от российских учёных поможет повысить пенсионный возраст на 10 лет ", "text": "Вице-премьер РФ Ангелина Голикова сообщила, что в ближайшее время в стране появится препарат, помогающий не только замедлить старение, но и частично вернуть молодость пожилым людям.  «Мы уже опробовали экспериментальные таблетки на членах правительства и заверяю вас, что работоспособность растёт как грибы после дождя – на пенсию не собирается никто», – заверила она. Препарат будет выпускаться в виде таблеток и раствора для инъекций. Год безостановочного применения лекарства может снизить биологический возраст пациента на три года. В будущем врачи в принудительном порядке заставят россиян принимать его по достижении 40 лет. Омолодившиеся россияне, помимо продления своего жизненного цикла, смогут принести пользу государству, ведь поспособствуют продлению трудоспособного возраста и снизят нагрузку на Социальный фонд.  «Помолодевшие люди смогут работать на двух-трёх работах и больше получать, а ещё у них вырастет стаж и пенсия, которую женщины будут получать в 70 лет, а мужчины – в 75 лет. Государство продолжит вкладывать средства в фармакологию для достижения ещё более впечатляющих результатов», – заявил министр труда и социальной политики Антон Котяков. Министр добавил, что в будущем каждый россиянин получит свой биометрический паспорт и в случае «отличного состояния здоровья» может быть отозван с пенсии.", "image": "https://panorama.pub/storage/images/fb/a2/93e9a22ae611915a6768bf3bf9ae/previews/37287-mw-1600.jpg.webp", "url": "https://panorama.pub/news/tehnologia-zamedlenia-starenia-ot-rossijskih"}, -{"title": "\n Госархив сообщил о «бесследной пропаже» рассекреченного в прошлом году договора РСФСР и США о продаже Камчатки ", "text": "Аккредитованный журналист Fox News сообщил, что не смог ознакомиться с текстом договора РСФСР и США 1922 года о продаже Камчатского полуострова: после месяца согласования посещения архива в здании был обнаружен очаг коронавируса, и после его ликвидации было заявлено о невозможности предоставить документ. Сотрудник Государственного архива России объяснил это «бесследной пропажей». С рассекречиванием части архивов в 2025 году широкой публике стало известно о заключённом в 1922 году договоре между США и Советской России. Ранее историкам было известно лишь о переговорах между по поводу передаче Камчатки американской стороне, что должно было поспособствовать выходу РСФСР из экономического кризиса. Впрочем, у историков ещё остаётся множество вопросов: так, совершенно неясно, был ли договор подписан и ратифицирован. Теперь, когда документ утрачен, потеряна и информация о возможных правах США на дальневосточный регион.", "image": "https://panorama.pub/storage/images/86/0e/b5d73805f53f3048a7692ffb704e/previews/37289-mw-1600.jpg.webp", "url": "https://panorama.pub/news/gosarhiv-soobsil-o-besslednoj-propaze"}, -{"title": "\n «Белые списки» сайтов привяжут к социальному рейтингу граждан ", "text": "С 1 февраля 2026 года доступ к интернет-ресурсам в мобильной\nсети будет дифференцирован в зависимости от уровня социального рейтинга\nгражданина. Соответствующее постановление утвердили Минцифры, Роскомнадзор и\nМинсвязи в рамках реализации концепции «ответственного цифрового\nгражданства». Теперь все россияне будут разделены на три категории по\nрезультатам оценки их цифрового поведения, участия в госпроектах, соблюдения\nзаконодательства и «вклада в укрепление национальных ценностей». От этой оценки\nбудет зависеть, какие сайты пользователь сможет открывать в мобильном\nинтернете. Гражданам с высоким социальным рейтингом – тем, кто\nрегулярно участвует в выборах, пользуется отечественными сервисами, проявляет\nлояльность в цифровой среде и не нарушает закон, – будет разрешён доступ ко всем\nсайтам, включая иностранные ресурсы, не запрещённые в РФ.  У\nграждан со средним рейтингом доступ сохранится только к полному перечню\nсуверенных ресурсов: отечественные СМИ, образовательные платформы, магазины,\nмессенджеры, облачные сервисы и порталы госорганов. Пользователи с низким\nрейтингом получат доступ исключительно к базовому набору: сайты федеральных\nтелеканалов, «Госуслуги», российские банки, Минобороны, Роспатриот и другие\n«патриотические» ресурсы, утверждённые правительственной комиссией по цифровой\nморали. Социальный рейтинг будет пересчитываться ежеквартально.\nПовысить его можно через участие в волонтёрских программах, оплату налогов без\nзадержек, обращения в госорганы через официальные каналы и публикации в\nподдержку государственной политики в верифицированных аккаунтах.   «Гражданин легко может определить свой\nсоциальный рейтинг по возможности открыть различные сайты. Это прозрачный и\nобъективный индикатор его вклада в цифровую стабильность страны. Чем больше вы вкладываетесь в общество, тем шире ваш цифровой горизонт. Переход к новой модели завершит формирование персонализированного суверенного интернета, где доступ к информации напрямую связан с гражданской ответственностью», – заявил замминистра связи Антон Логинов на заседании коллегии ведомства.", "image": "https://panorama.pub/storage/images/59/e8/562e230c82d002083c9b63c55a9e/previews/37302-mw-1600.jpg.webp", "url": "https://panorama.pub/news/belye-spiski-sajtov-privazut-k"}, -{"title": "\n Кошка назначена директором крупнейшей российской библиотеки ", "text": "Премьер-министр Михаил Мишустин подписал указ о назначении нового генерального директора Российской государственной библиотеки (РГБ). Впервые в полуторавековой истории «Ленинки» учреждение культуры возглавит кошка. «Мы считаем, что именно Фелиса Олеговна сможет предать новый импульс развития крупнейшей отечественной библиотеки. Мы ждём от неё новых подходов, решений, креатива, одним словом», – сказала замминистра культуры Жанна Алексеева. Своим первым указом новая глава РГБ запретила посещать библиотеку собакам и котофобам. Также в ближайшее время в учреждении культуры появится специальный «кошачий» отдел. «Это будет коллекция, в которую будут включены все типы изданий о кошках – книги, журналы, плакаты и даже интернет-мемы. По предварительным оценкам, речь идёт как минимум о 100 тысячах единиц хранения», – говорится в официальном пресс-релизе «Ленинки».", "image": "https://panorama.pub/storage/images/b4/ca/deb543cdc88edbc90528b764de0f/previews/37311-mw-1600.jpg.webp", "url": "https://panorama.pub/news/koska-naznacena-direktorom-krupnejsej-rossijskoj"}, -{"title": "\n Стивен Уиткофф: переговоры идут хорошо, мы уже дошли до эпохи Ивана III ", "text": "Спецпредставитель США по урегулированию конфликта между Россией и Украиной Стивен Уиткофф в интервью Fox News подтвердил успешное продвижение на переговорном треке по установлению первопричин украинского конфликта. Кроме того, он в юбилейный 100-й раз заявил о том, что всё прошло «продуктивно». Общаясь с журналистами во Флориде, Уиткофф предположил, что смог бы стать университетским профессором и преподавать историю России. «Переговоры идут действительно хорошо. Вы знаете, я никогда толком не учил историю и совершенно ничего не знал о России до назначения на этот пост... Господин Путин согласился объяснить мне некоторые сложные моменты, а затем начал присылать своих переговорщиков с той же целью. Наши регулярные занятия проходят успешно, как и любые переговоры под руководством Дональда Трампа – они всегда проходят очень хорошо. Сейчас мы уже на этапе образования централизованного российского государства, проходим Ивана III», – поделился Уиткофф. «Иногда я выхожу из banya после переговоров в Москве, смотрю на звёздное небо, обтираюсь снегом и думаю, что начинаю что-то понимать в истоках украинского кризиса», – добавил политик. Стивен Уиткофф также отверг выпады противников о том, что он якобы во всём доверяет российской стороне: «Тему об образовании древнерусского государства я самостоятельно изучал по видеоурокам, которые мне любезно предоставили мои русские друзья, а также проверял информацию по книгам известного русского историка – он мне подарил их после окончания раунда переговоров».", "image": "https://panorama.pub/storage/images/a0/51/0749bdd34c61f7e5131d6f67ad7a/previews/37308-mw-1600.jpg.webp", "url": "https://panorama.pub/news/stiven-uitkoff-peregovory-idut-horoso"}, -{"title": "\n Все российские бояре пересели на «Москвич» ", "text": "На императорском заводе АЗЛК звучат фанфары – последний российский боярин Мухамедов пересел с заморского «Порше» на отечественный «Москвич», тем самым завершив программу коренизации, запущенную десять лет по замыслу Государя.  Поначалу бояре не хотели брать «Москвич» и «Электромосквич», предпочитая им забугорные телеги, несмотря на их дороговизну. Все увещевания и лекции о тлетворности иноземного автопрома не действовали, даже когда на Боярском собрании вывесили фотокартины с ликами отщепенцев, это не возымело действия – бояре начали оформлять импортные повозки на своих чад и супружниц.  «Тогда мы решили создавать бригады черносотенцев – дружинники-патриоты брали кувалду или молот и после заката в хламину долбали эти роскошные телеги бояр. Сия гениальная идея впоследствии дала им осознать, что лучше ездить на российском автомобиле», – заключил глава опричников, боярин Скуратов-Бельский.  А вслед за боярами на «Москвич» пересели и посадники рангом ниже, а акции российского автогиганта котируются на Московской бирже очень высоко.", "image": "https://panorama.pub/storage/images/a6/af/37015d66140c3438c90fd0d53789/previews/37309-mw-1600.jpg.webp", "url": "https://panorama.pub/news/vse-rossijskie-boare-pereseli-na"}, -{"title": "\n Российские авиакомпании прорабатывают возможность возврата в эксплуатацию самолётов Ту-154 ", "text": "Российские авиаперевозчики начали масштабный процесс расконсервации и возврата в эксплуатацию ранее поставленных на хранение самолётов, чтобы компенсировать нехватку авиапарка и поддержать рост перевозок. Ранее сообщалось о возврате в небо лайнеров моделей Ил-96, Ан-148 и Ту-204, но теперь черёд может дойти и до модели Ту-154. В настоящее время на хранении числится около 60 машин этой модели, а ещё столько же продолжают эксплуатироваться госструктурами. Если удастся вернуть их в работу, это закроет потребности российских авиакомпаний на десятилетия. При этом, как утверждают эксперты в Росавиации, срок хранения воздушного судна не ограничен – возврат самолёта в небо зависит только от условий, в которых он хранился. Сейчас специалисты ведомства проверяют уцелевшие борты на возможность эксплуатации. Заслуженный пилот России Юрий Сытник поддержал возвращение в эксплуатацию старых самолётов. «Рисков абсолютно никаких нет – это надёжные машины, незаслуженно забытые в России. Среди них есть машины, которые даже и двух ресурсов не отработали», – сказал он.", "image": "https://panorama.pub/storage/images/ee/38/7fd4188ca239ec23b2d146505600/previews/37310-mw-1600.jpg.webp", "url": "https://panorama.pub/news/rossijskie-aviakompanii-prorabatyvaut-vozmoznost-vozvrata"}, -{"title": "\n Из-за хронической депрессии каждый третий житель Екатеринбурга испытывает трудности при оплате улыбкой ", "text": "В Екатеринбурге участились случаи неудачных транзакций при попытке безналичной оплаты с помощью технологии smile-pay. Попытки улыбнуться платёжному терминалу заканчиваются фиаско в 75% случаев, что приводит к образованию очередей и обострению общего уныния. По данным соцопроса, 11% жителей города признались, что так ни разу и не смогли улыбнуться в продуктовом магазине на кассе, лишая себя возможности приобрести базовые товары первой необходимости. Муниципальные власти рассматривают возможность введения специальных курсов лицевой гимнастики, однако эксперты сомневаются в их эффективности на фоне тотального экзистенциального кризиса. Торговые сети фиксируют рост продаж масок с нарисованной улыбкой, но терминалы всё чаще отказываются их распознавать, требуя подлинных эмоций. Ситуация усугубляется тем, что каждый проваленный сеанс smile-pay автоматически списывает с карты 15 рублей в качестве штрафа за «некорректный эмоциональный ответ». «Это замкнутый круг. Чтобы купить хлеб, нужна улыбка. Чтобы родилась улыбка, нужно хоть что-то, ради чего стоит жить. А где это взять, если ты уже три дня не можешь купить хлеб? Терминал моргает красным светом, а за спиной вздыхают такие же. И понимаешь, что даже эта синтетическая радость стала недоступной валютой», – поделился своим опытом местный житель, пожелавший остаться неизвестным.", "image": "https://panorama.pub/storage/images/6e/a1/ffecc77e15fc9cc5d85f4c24409d/previews/37312-mw-1600.jpg.webp", "url": "https://panorama.pub/news/iz-za-hroniceskoj-depressii-kazdyj"}, -{"title": "\n В честь Дня святой Татьяны студентам простили все долги по сессии ", "text": "Министр просвещения Сергей Кравцов подписал указ «О порядке списания академической задолженность в связи с празднованием Дня российского студенчества». Согласно документу, 25 января 2026 года все долги учащихся отечественных высших учебных заведений по зимней сессии будут списаны, а полученные ранее «неуды» – заменены на оценки «удовлетворительно». «Уверен, что товарищи студенты оценят этот подарок ко Дню святой Татьяны. Ребята могут теперь не переживать, а спокойно отмечать свой праздник», – сказал Кравцов. Как подчеркнул министр, если кто-то из студентов планирует пересдать экзамен на «хорошо» или «отлично», то он может подать заявление в деканат и «попробовать поймать халяву за хвост».", "image": "https://panorama.pub/storage/images/55/59/0d851916c8a4fd90d68ca31c38be/previews/37322-mw-1600.jpg.webp", "url": "https://panorama.pub/news/v-cest-dna-svatoj-tatany"}, -{"title": "\n Лечащий психиатр Дональда Трампа извинился за события в Венесуэле и Гренландии, произошедшие во время его отпуска ", "text": "Доктор Маркус Хейлз публично выразил сожаление по поводу геополитических инцидентов, случившихся в январе 2026 года. По его словам, ответственность лежит на нём, так как только он может обеспечить регулярный приём пациентом необходимых препаратов. Во время трехнедельного отпуска доктора Хейлза подопечный самостоятельно прекратил медикаментозный курс. Это привело к серии экстренных телефонных переговоров, закончившихся тайной операцией по вывозу действующего президента Венесуэлы и официальным запросом о покупке автономной территории Дании. Ситуация стабилизировалась лишь после срочного возвращения врача, который лично проконтролировал приём успокоительного средства. «Это моя вина, я ослабил бдительность, – заявил доктор Хейлз. – Без химического сдерживания мир для него – карта из детского атласа, где можно стереть границы пальцем. А мы все потом ходим по этим выжженным пятнам и делаем вид, что так и надо. Теперь снова начнётся монотонная, серая, правильная жизнь, пока я снова не уеду или пока он снова не выплюнет таблетку в ладонь».", "image": "https://panorama.pub/storage/images/3d/6e/40f4b47261b4ed7ca57001b18d1e/previews/37325-mw-1600.jpg.webp", "url": "https://panorama.pub/news/lecasij-psihiatr-donalda-trampa-izvinilsa"}, -{"title": "\n Акустики НАТО в Швеции 60 лет слушали звуки советского клюкала, принимая его за вражеские подлодки ", "text": "На протяжении 60 лет специалисты Североатлантического альянса в районе шведских шхер фиксировали таинственные подводные акустические аномалии. Источник сигналов, названный «Шепчущим Иваном», так и не был идентифицирован, что привело к многомиллионным затратам на модернизацию систем гидропеленгации. Основные подозрения падали на сверхсекретные советские, а затем и российские субмарины, обладающие бесшумным ходом. Оборонные ведомства скандинавских стран регулярно выпускали тревожные отчёты, а в прессе появлялись панические материалы о «подводном флоте призраков». Лишь сейчас, в ходе рассекречивания архивов одного из обанкротившихся архангельских лесопромышленных комбинатов, была обнаружена техническая документация на утилизированное оборудование. Согласно ей, в 1963 году в Белом море затонула баржа, гружённая партией экспериментальных клюкал для массового туристического похода комсомольцев по Кольскому полуострову. Титановая спираль внутри каждого клюкала, вибрируя от течений, и издавала тот самый загадочный звук.", "image": "https://panorama.pub/storage/images/3c/d5/d4feb54bced2afd6694b73d02f2c/previews/37326-mw-1600.jpg.webp", "url": "https://panorama.pub/news/akustiki-nato-v-svecii-60"}, -{"title": "\n Из-за аномальных морозов в Москве замёрзла вся водка ", "text": "В Москве за последние дни резко выросли объёмы реализации медицинского спирта – аномальные морозы привели к замерзанию всей водки в столице, что сделало невозможным её употребление внутрь. «Оперативно реагируя на вызовы природы, мы кратно увеличили объёмы отпускаемого гражданам спирта. Прошу москвичей и гостей столицы не волноваться, его хватит на всех», – сказал заммэра столицы Алексей Ерохин.  Доктор медицинских наук и телеведущий Александр Мясников призвал жителей столицы употреблять спирт строго в соответствии с утверждённым Министерством здравоохранения стандартом. «Налил рюмочку и, не вдыхая, махнуть её залпом. После досчитать до десяти, резко выдохнуть и запить водичкой», – написал врач в своём Telegram-канале.", "image": "https://panorama.pub/storage/images/74/96/8a029d36b6d59bf2f2dd0822508d/previews/37341-mw-1600.jpg.webp", "url": "https://panorama.pub/news/iz-za-anomalnyh-morozov-v"}, -{"title": "\n Disney приобрёл эксклюзивные права на экранизацию книг Говарда Лавкрафта ", "text": "Корпорация Disney объявила о завершении сделки по приобретению эксклюзивных прав на все произведения Говарда Филлипса Лавкрафта. В планах медиагиганта – создание кинематографической вселенной, где древние боги обретут голоса известных актёров и запоют о дружбе. Мировая общественность в тихом ужасе наблюдает за неизбежным. По данным инсайдеров, первым проектом станет полнометражный мюзикл «Ктулху: Морская песенка», сочетающий CGI-ужасы и трогательную историю о самопознании. Сценаристы уже работают над переосмыслением «Зова Ктулху» как истории о милом подводном существе, мечтающем стать знаменитым певцом. Параллельно запущена линейка детских плюшевых игрушек «Милые Древние» с глазами-бусинками. Фанаты оригинальных текстов безуспешно пытаются забыться сном, но тщетно – маркетинг уже проник в сны. «Мы верим, что вселенная Лавкрафта идеально ложится на наши ценности инклюзивности и разнообразия, – заявил вице-президент Disney по креативным франшизам. – За внешним ужасом и безумием скрываются вечные темы: одиночество, поиск своего места в мире и важность сплочённости против внешних угрожающих сил. Мы просто добавим катарсис, хеппи-энд и запоминающийся саундтрек. Наши аниматоры вдохновлены – они никогда ещё не рисовали столько щупалец в пастельных тонах».", "image": "https://panorama.pub/storage/images/f9/61/e470c4bca4425c2a3641be82f97f/previews/37342-mw-1600.jpg.webp", "url": "https://panorama.pub/news/disney-priobrel-ekskluzivnye-prava-na"}, -{"title": "\n Международное движение русофилов торжественно отметило вступление тысячного члена ", "text": "На торжественном собрании в Цюрихе в понедельник Международное движение дружбы и поддержки российских ценностей (МДПРЦ) официально объявило о принятии в свои ряды тысячного участника. Глава движения заявил, что с момента его создания в 1997 году оно проделало огромный путь. Основное мероприятие прошло в формате видеоконференции, где девятьсот девяносто девять членов из одиннадцати стран, включая самого председателя, поприветствовали нового активиста, гражданина Швейцарии Генриха Мюллера, подключившегося со своего домашнего компьютера. После ратификации членского билета №1000 председатель МДПРЦ попросил МИД России увеличить финансирование ещё на 15 млн долларов в год для расширения деятельности, в частности, на печать календарей и оплату аренды виртуального сервера. Почётный член организации, гражданин Австралии Джеймс О’Райли, не смог присутствовать на подключении из-за разницы в часовых поясах.", "image": "https://panorama.pub/storage/images/d3/95/0ac384293a0d224034ff5eba4dc1/previews/37343-mw-1600.jpg.webp", "url": "https://panorama.pub/news/mezdunarodnoe-dvizenie-rusofilov-torzestvenno-otmetilo"}, -{"title": "\n «Границы 1991 года – это минимум»: Борис Джонсон войдёт в украинскую делегацию на следующих переговорах с Россией ", "text": "Британский политик Борис Джонсон войдёт в состав украинской делегации на следующих переговорах с Россией по завершению конфликта – об этом объявил премьер-министр Великобритании Кир Стармер. Сам Джонсон подтвердил информацию и заявил, что намерен «говорить с русскими с позиции силы». Ранее The Times сообщал, что многие в Лондоне были «встревожены», когда один из украинских переговорщиков в интервью Axios назвал первый раунд переговоров позитивным. «Это то, что случается, когда вы игнорируете инструкции партнёров. Мы должны вмешаться и положить конец легкомысленному подходу, потому что на кону стоит безопасность всей Европы», – сказал газете неназванный высокопоставленный чиновник. По словам Стармера, Джонсон должен «усилить позиции» украинской делегации и проследить, чтобы никто из её членов не пытался отступить от выработанной общей линии. Экс-премьер в свою очередь пообещал, что никакие красные линии больше не будут нарушены. «Есть чёткая позиция украинского народа о том, как должно всё закончиться: выход на границы 1991 года, многомиллиардные репарации, вступление в НАТО. Это минимум, хотя мы имеем моральное право запросить намного больше. Мы не сойдём с этих позиций и будем добиваться справедливости. Я буду говорить с русскими с позиции силы, потому что мы едем в Абу-Даби зафиксировать их поражение, а не обсуждать их абсурдные требования», – заявил он.", "image": "https://panorama.pub/storage/images/87/60/9f8ea87fba6d02896c13864285c3/previews/37346-mw-1600.jpeg.webp", "url": "https://panorama.pub/news/granicy-1991-goda--"}, -{"title": "\n В Москве бульдозерами уничтожили 100 VPN-серверов, конфискованных в подпольных дата-центрах Google ", "text": "На подмосковном полигоне «Улыбка природы» состоялась первая в 2026 году процедура утилизации 100 VPN-серверов компании Google, которые американская компания последние годы использовала для создания незаконных маршрутов интернет-трафика. Изъять оборудование удалось благодаря бдительности столичного дворника – мужчина обратил внимание, что в здании бывшей подстанции Московского трансформаторного завода появились подозрительные люди, которые установили «гудящие чёрные ящики». «Сначала мы подумали, что речь идёт об обычном майнинге криптовалют. Однако правда оказалась намного страшнее, именно тут Google установил свои VPN-сервера, которые помогали преступникам получать доступ к запрещённой информации», – сказал заммэра по контролю и инновациям Гамлет Лероян. Сообщивший о подпольном Data-центре американской компании дворник получил крупную премию, грамоту от столичной мэрии и удостоверение участника государственной программы «Соотечественники». Также сотрудниками полиции были задержаны 28 обслуживающих сервера граждан, все они арестованы, им грозит от 10 до 15 лет заключения.", "image": "https://panorama.pub/storage/images/18/ad/53ce407a265553d2d6cea6408db8/previews/37345-mw-1600.jpg.webp", "url": "https://panorama.pub/news/v-moskve-buldozerami-unictozili-100"}, -{"title": "\n Мошенники убедили бывшего сотрудника МИД отдать 150 миллионов рублей для освобождения Мадуро ", "text": "В столичную полицию обратился 70-летний пенсионер, который стал очередной жертвой телефонных мошенников. В середине января мужчине позвонил неизвестный, который представился сотрудником центрального аппарата МИД и попросил «оказать услугу стране и дружественным лидерам». «Я сам бывший дипломат, поэтому сразу откликнулся. Мне предстояло принять участие в спецоперации по подкупу агента ЦРУ, который должен был помочь освободить незаконно арестованного товарища Мадуро», – сказал потерпевший. Преступники приказали пенсионеру снять со счетов все свои сбережения и передать «кадровому офицеру американской разведки». Как заверили экс-сотрудника МИД злоумышленники, после завершения операции он бы получил полную компенсацию потраченных средств, государственную награду и нефтяную вышку в Венесуэле. Всего потерпевший передал мошенникам более 150 миллионов рублей. Совершив последний перевод, он позвонил в МИД и попросил уточнить, когда деньги вернутся на его счёт. Узнав, что его обманули, пенсионер незамедлительно написал заявления в СМИ, правоохранительные органы и в российский МИД.", "image": "https://panorama.pub/storage/images/ce/0c/e90a7e43ed766511043e0d871b6a/previews/37344-mw-1600.jpg.webp", "url": "https://panorama.pub/news/mosenniki-ubedili-moskvica-otdat-150"}, -{"title": "\n При обыске у главаря военного мятежа в Китае обнаружен неисправный дискомбобулятор ", "text": "На брифинге в понедельник министерство государственной\nбезопасности Китая показало журналистам прибор, который, как утверждается,\nиспользовали мятежники при попытке нейтрализации охраны председателя КНР Си Цзинпина.\nСудя по внешнему виду и маркировке, это дискомбобулятор, который американцы\nранее применили в Венесуэле. Покров секретности с устройства под названием\n«дискомбобулятор» на днях сорвал президент США Дональд Трамп. Он признал, что\nспецподразделение «Дельта» дезориентировало с его помощью вооружённые силы\nВенесуэлы, в результате чего они не смогли помешать захвату президента Мадуро. Аналитики\nсчитают, что в момент этого объявления Трамп уже знал, что дискомбобулятор\nпопал в руки китайского МГБ. «Арестованный по обвинению в государственном мятеже генерал\nЧжан Юся пытался коварно избавиться от прибора, который изобличал его участие в\nпреступлении. На допросе он лицемерил, утверждая, что не знает, что это за\nприбор и как он попал под его кровать», – говорится в пресс-релизе МГБ. Эксперты, специализирующиеся на военной электронике,\nпредполагают, что на пресс-конференции китайские спецслужбы показали не\nамериканский дискомбобулятор, а его низкокачественную китайскую копию. Такой\nвывод они сделали, увидев вилку питания китайского образца и дешёвый пластик\nкорпуса. На корпусе видны следы перегрева, из-за чего, вероятно, устройство не\nсработало должным образом, телохранители председателя Си остались\nнедезориентированными, и мятежников арестовали. Администрация президента США уточнила, что все американские\nдискомбобуляторы находятся на своём месте и не могли быть использованы в Китае.", "image": "https://panorama.pub/storage/images/63/78/1e26a69542a7b6a4416046d50660/previews/37357-mw-1600.jpg.webp", "url": "https://panorama.pub/news/pri-obyske-u-glavara-voennogo"}, -{"title": "\n 39 доносов друг на друга за сутки: платформа российской оппозиции в ПАСЕ со скандалом распалась сразу после запуска ", "text": "В Парламентской ассамблее Совета Европы (ПАСЕ) прекратила свою работу платформа российской оппозиции, запущенная менее суток назад. Причиной послужили многочисленные конфликты между её участниками – по словам источника в ПАСЕ, в первые же часы работы в комитет внутренней безопасности ассамблеи начали поступать доносы. При том, что в платформу входят всего 15 делегатов, в комитет поступили 39 доносов – оппозиционеры обвиняли друг друга в работе на правительство России и ФСБ, а один из оппозиционеров по имени Гарри К. (фамилия скрыта из соображений анонимности) по старой привычке использовал аббревиатуру КГБ. В итоге через 14 часов после запуска в ПАСЕ решили приостановить работу объединения в связи с «неконструктивными способами ведения дискуссии». Как сообщает EU Observer, в ПАСЕ и так предприняли ряд мер предосторожности: в частности, оппозиционеров разделили на три отдельных группы, чтобы минимизировать вероятность драк на заседаниях, однако на первой же встрече кто-то пронёс с собой дальнобойную рогатку, в результате чего вышеупомянутый Гарри К. был госпитализирован с поверхностной гематомой в зоне regio frontalis.", "image": "https://panorama.pub/storage/images/e6/5c/de29ae1a2c4cc1848279ccc8f6b3/previews/37363-mw-1600.jpg.webp", "url": "https://panorama.pub/news/39-donosov-drug-na-druga"}, -{"title": "\n Минкульт оградит детей от сказочных животных, не соответствующих религиозно-санитарным нормам ", "text": "Министерство культуры РФ рекомендовало исключить сказку «Три поросёнка» из школьных учебников и списков литературы, рекомендованной для самостоятельного прочтения детьми, сославшись на нарушение санитарных норм главных персонажей, пропаганду аварийного жилья и дискредитацию отечественного волка. В ведомстве заявили, что основные герои произведения – свиньи – животные, считающиеся нечистыми в большинстве традиционных для России религий. Представление таких персонажей в положительном и назидательном ключе, по мнению экспертов, подрывает основы культурной и духовной гигиены и формирует у детей размытое представление о допустимых нормах. Отдельные претензии вызвали описания быта и архитектуры в сказке – так, строительная практика поросят признана плохим примером для подрастающего поколения, а использование соломы и веток в министерстве расценивают как популяризацию халтурного подхода к формированию жилищного фонда, игнорирование ГОСТов, а также глумление над самой идеей дома как традиционной ценности. Особое возмущение экспертов Минкульта вызвало искажение образа волка – в «Трёх поросятах» он представлен как объект насмешек и коллективного давления со стороны животных, не соответствующих религиозно-санитарным нормам, тогда как в русской сказочной традиции волк зачастую играет положительную роль – помощника героя, носителя силы и справедливости, что наглядно показано в сказке «Иван-царевич и Серый волк». «Мы не можем позволить, чтобы под видом невинной сказки детям навязывались чуждые модели поведения, порочные строительные стандарты и неуважение к нашим культурным кодам», – заявил глава департамента Минкульта по редактированию детской художественной литературы Олег Латунский.  Обновлённые учебники поступят в школы к 1 сентября текущего года, а пока ученикам и преподавателям надлежит ножницами или бритвенным лезвием самостоятельно исключить нерекомендованные фрагменты в соответствии с рекомендациями Минкульта.", "image": "https://panorama.pub/storage/images/49/0d/747820a745175bbd40816d050ddb/previews/37365-mw-1600.jpg.webp", "url": "https://panorama.pub/news/opasnaa-skazka-detej-ogradat-ot"} -] \ No newline at end of file diff --git a/scheduler.py b/scheduler.py index 4642e33..6a6dfb7 100644 --- a/scheduler.py +++ b/scheduler.py @@ -30,7 +30,7 @@ def run_scheduler(): try: logger.info("...Планировщик запущен...") run_spider() - scheduler.add_job(run_spider, "interval", minutes=1) + scheduler.add_job(run_spider, "interval", minutes=30) scheduler.start() except Exception as e: logger.error(f"Ошибкка в планировщике: {e}") diff --git a/storage.py b/storage.py index 0c315f7..9952c1d 100644 --- a/storage.py +++ b/storage.py @@ -19,6 +19,12 @@ def init_db(): text TEXT, create_at TEXT ) +""") + conn.execute(""" + CREATE TABLE IF NOT EXISTS last_sent_news ( + chat_id INTEGER PRIMARY KEY, + last_news_id INTEGER + ) """) conn.commit() @@ -45,12 +51,46 @@ def save_news(url, title, image, text): conn.commit() -def get_news_after_id(last_id): +def get_news_after_id(last_id, n=10): with get_connection() as conn: cursor = conn.execute( """ - SELECT id, title, image, text, url FROM news WHERE id > ? ORDER BY id ASC + SELECT id, title, image, text, url FROM news WHERE id > ? ORDER BY id DESC LIMIT ? """, - (last_id,), + (last_id, n), ) - return cursor.fetchall() + return cursor.fetchall()[::-1] + + +def get_last_sent_id(chat_id): + with get_connection() as conn: + cursor = conn.execute( + """ + SELECT last_news_id FROM last_sent_news WHERE chat_id = ? +""", + (chat_id,), + ) + row = cursor.fetchone() + if row: + return row[0] + else: + return 0 + + +def save_last_sent_news_id(chat_id, last_id): + with get_connection() as conn: + conn.execute( + """ + INSERT INTO last_sent_news (chat_id, last_news_id) VALUES (?, ?) + ON CONFLICT(chat_id) DO UPDATE SET last_news_id = excluded.last_news_id""", + (chat_id, last_id), + ) + conn.commit() + + +def get_all_users(): + with get_connection() as conn: + cursor = conn.execute(""" + SELECT chat_id FROM last_sent_news +""") + return [row[0] for row in cursor.fetchall()] From 484c20d4944720477cc6bfcadab4b105c7d5a448 Mon Sep 17 00:00:00 2001 From: Dmitry Titenkov Date: Wed, 11 Feb 2026 16:35:33 +0300 Subject: [PATCH 17/27] =?UTF-8?q?=D0=91=D0=B0=D0=B7=D0=B0=20PostgreSQL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 5 + Dockerfile | 8 ++ bot.py | 115 +++++++++++++--------- bot_storage.py | 79 +++++++++++++++ db_async.py | 12 +++ db_sync.py | 12 +++ docker-compose.yml | 33 +++++++ init_db.py | 22 +++++ main.py | 2 - models.py | 30 ++++++ requirements.txt | 4 + satire_pulp_parser/spiders/satire_pulp.py | 17 ++-- scheduler.py | 3 +- spider_storage.py | 27 +++++ storage.py | 96 ------------------ 15 files changed, 313 insertions(+), 152 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 bot_storage.py create mode 100644 db_async.py create mode 100644 db_sync.py create mode 100644 docker-compose.yml create mode 100644 init_db.py create mode 100644 models.py create mode 100644 spider_storage.py delete mode 100644 storage.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..956a693 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +__pycache__ +.env +.git +.venv +.idea \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..91edc3d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,8 @@ +FROM python:3.11-slim +LABEL maintainer="Dmitry Titenkov " +LABEL version="1.0" +LABEL description="Satire Pulp parser" +WORKDIR /app +COPY requirements.txt /app +RUN pip3 install -r /app/requirements.txt --no-cache-dir +COPY . . diff --git a/bot.py b/bot.py index 0c396eb..b696c54 100644 --- a/bot.py +++ b/bot.py @@ -1,13 +1,14 @@ import logging -from config import setup_logger -from dotenv import load_dotenv -from storage import ( +from bot_storage import ( get_all_users, get_last_sent_id, get_news_after_id, save_last_sent_news_id, ) +from config import setup_logger +from db_async import AsyncSessionLocal +from dotenv import load_dotenv from telegram import ( BotCommand, InlineKeyboardButton, @@ -67,22 +68,30 @@ async def send_news( async def auto_send_news(context: ContextTypes.DEFAULT_TYPE): - users = get_all_users() - if not users: - return - for chat_id in users: - last_id = get_last_sent_id(chat_id) - news_list = get_news_after_id(last_id) - if not news_list: + async with AsyncSessionLocal() as session: + users = await get_all_users(session) + if not users: return - for news_id, title, image, text, url in news_list: - try: - await send_news(chat_id, context, title, image, text, url) - save_last_sent_news_id(chat_id, news_id) - except Exception as e: - logger.error( - f"Ошибка при автоматической отправке новости: {e}" - ) + for chat_id in users: + last_id = await get_last_sent_id(chat_id, session) + news_list = await get_news_after_id(last_id, session) + if not news_list: + continue + for news in news_list: + try: + await send_news( + chat_id, + context, + news.title, + news.image, + news.text, + news.url, + ) + await save_last_sent_news_id(chat_id, news.id, session) + except Exception as e: + logger.error( + f"Ошибка при автоматической отправке новости: {e}" + ) async def menu(update: Update, context: ContextTypes.DEFAULT_TYPE): @@ -115,20 +124,28 @@ async def button_handler(update: Update, context: ContextTypes.DEFAULT_TYPE): logger.warning(f"callback_query не удалось ответить: {e}") if query.data == "send_news": chat_id = query.message.chat_id - last_id = get_last_sent_id(chat_id) - news_list = get_news_after_id(last_id) - if not news_list: - await query.message.reply_text("Новых новостей пока нет 🙁") - logger.info("Новых новостей нет") - return - for news_id, title, image, text, url in news_list: - try: - await send_news(chat_id, context, title, image, text, url) - save_last_sent_news_id(chat_id, news_id) - except Exception as e: - logger.error( - f"Ошибка при отправке новости '{title[:25]}': {e}" - ) + async with AsyncSessionLocal() as session: + last_id = await get_last_sent_id(chat_id, session) + news_list = await get_news_after_id(last_id, session) + if not news_list: + await query.message.reply_text("Новых новостей пока нет 🙁") + logger.info("Новых новостей нет") + return + for news in news_list: + try: + await send_news( + chat_id, + context, + news.title, + news.image, + news.text, + news.url, + ) + await save_last_sent_news_id(chat_id, news.id, session) + except Exception as e: + logger.error( + f"Ошибка при отправке новости '{news.title[:25]}': {e}" + ) elif query.data == "help": help_text = "Нажмите 📰 'Показать новости', чтобы получить новости." await query.message.reply_text(help_text) @@ -138,18 +155,28 @@ async def show_news_command( update: Update, context: ContextTypes.DEFAULT_TYPE ): chat_id = update.message.chat_id - last_id = get_last_sent_id(chat_id) - news_list = get_news_after_id(last_id) - if not news_list: - await update.message.reply_text("Новостей пока нет 🙁") - logger.info("Новостей нет") - return - try: - for news_id, title, image, text, url in news_list: - await send_news(chat_id, context, title, image, text, url) - save_last_sent_news_id(chat_id, news_id) - except Exception as e: - logger.error(f"Ошибка при отправке новости '{title[:25]}': {e}") + async with AsyncSessionLocal() as session: + last_id = await get_last_sent_id(chat_id, session) + news_list = await get_news_after_id(last_id, session) + if not news_list: + await update.message.reply_text("Новостей пока нет 🙁") + logger.info("Новостей нет") + return + try: + for news in news_list: + await send_news( + chat_id, + context, + news.title, + news.image, + news.text, + news.url, + ) + await save_last_sent_news_id(chat_id, news.id, session) + except Exception as e: + logger.error( + f"Ошибка при отправке новости '{news.title[:25]}': {e}" + ) async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE): diff --git a/bot_storage.py b/bot_storage.py new file mode 100644 index 0000000..5ba4e5d --- /dev/null +++ b/bot_storage.py @@ -0,0 +1,79 @@ +import logging + +from models import LastSentNews, News +from sqlalchemy import select +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.ext.asyncio import AsyncSession + +logger = logging.getLogger(__name__) + + +async def get_news_after_id(last_id: int, session: AsyncSession, n=10): + """ + Получение новостей если если id новости меньше чем last_id отправленной новости, + либо получение 10 последних новостей если last_id = 0 + """ + try: + if last_id == 0: + news = await session.execute( + select(News) + .where(News.id > last_id) + .order_by(News.id.desc()) + .limit(n) + ) + return news.scalars().all()[::-1] + else: + news = await session.execute( + select(News).where(News.id > last_id).order_by(News.id.asc()) + ) + return news.scalars().all() + except SQLAlchemyError as e: + logger.error(f"Ошибка при получении списка новостей: {e}") + raise + + +async def get_last_sent_id(chat_id: int, session: AsyncSession): + """Получение last_id последней отправленной новости""" + try: + last_sent = await session.execute( + select(LastSentNews).where(LastSentNews.chat_id == chat_id) + ) + last_sent_id = last_sent.scalar_one_or_none() + if last_sent_id: + return last_sent_id.last_news_id + else: + return 0 + except SQLAlchemyError as e: + logger.error( + f"Ошибка при получении id последней отправленной новости: {e}" + ) + + +async def save_last_sent_news_id( + chat_id: int, last_id: int, session: AsyncSession +): + """Сохранение last_id последней отправленной новости""" + try: + last_sent = await session.execute( + select(LastSentNews).where(LastSentNews.chat_id == chat_id) + ) + last_sent = last_sent.scalar_one_or_none() + if last_sent: + last_sent.last_news_id = last_id + else: + last_sent = LastSentNews(chat_id=chat_id, last_news_id=last_id) + session.add(last_sent) + await session.commit() + except SQLAlchemyError as e: + await session.rollback() + logger.error(f"Ошибка при сохранении last_news_id: {e}") + raise + + +async def get_all_users(session: AsyncSession): + """Получение id юзера""" + try: + users = await session.execute(select(LastSentNews.chat_id)) + return users.scalars().all() + except SQLAlchemyError as e: + logger.error(f"Ошибка получения chat_id пользователей: {e}") diff --git a/db_async.py b/db_async.py new file mode 100644 index 0000000..5d00ea3 --- /dev/null +++ b/db_async.py @@ -0,0 +1,12 @@ +import os + +from dotenv import load_dotenv +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +load_dotenv() + + +engine = create_async_engine(os.getenv("DATABASE_URL_ASYNC"), echo=False) + + +AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False) diff --git a/db_sync.py b/db_sync.py new file mode 100644 index 0000000..1423a78 --- /dev/null +++ b/db_sync.py @@ -0,0 +1,12 @@ +import os + +from dotenv import load_dotenv +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +load_dotenv() + + +engine = create_engine(os.getenv("DATABASE_URL_SYNC")) + +SessionLocal = sessionmaker(engine) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2827f07 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,33 @@ +version: '3.8' + +services: + db: + image: postgres:15.0-alpine + volumes: + - postgres_data:/var/lib/postgresql/data/ + ports: + - "5432:5432" + env_file: + - ./.env + + bot: + build: . + restart: always + depends_on: + - db + env_file: + - ./.env + command: python3 main.py + + scheduler: + build: . + restart: always + command: python3 scheduler.py + depends_on: + - db + - bot + env_file: + - ./.env + +volumes: + postgres_data: \ No newline at end of file diff --git a/init_db.py b/init_db.py new file mode 100644 index 0000000..7fe950b --- /dev/null +++ b/init_db.py @@ -0,0 +1,22 @@ +import asyncio +import logging + +from config import setup_logger +from db_async import engine +from dotenv import load_dotenv +from models import Base, LastSentNews, News # noqa + +load_dotenv() + +setup_logger() +logger = logging.getLogger(__name__) + + +async def init(): + """Создание таблиц в базе""" + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + logger.info("...Таблицы созданы...") + + +asyncio.run(init()) diff --git a/main.py b/main.py index e39e29f..306aea6 100644 --- a/main.py +++ b/main.py @@ -12,7 +12,6 @@ ) from config import setup_logger from dotenv import load_dotenv -from storage import init_db from telegram.ext import ( ApplicationBuilder, CallbackQueryHandler, @@ -26,7 +25,6 @@ def main(): - init_db() app = ApplicationBuilder().token(os.getenv("TELEGRAM_TOKEN")).build() app.job_queue.run_repeating(auto_send_news, interval=600, first=10) app.add_handler(CommandHandler("start", menu)) diff --git a/models.py b/models.py new file mode 100644 index 0000000..89c0fe4 --- /dev/null +++ b/models.py @@ -0,0 +1,30 @@ +from datetime import datetime + +from sqlalchemy import BigInteger, DateTime, Integer, String, Text, func +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class Base(DeclarativeBase): + pass + + +class News(Base): + __tablename__ = "news" + + id: Mapped[int] = mapped_column(primary_key=True) + url: Mapped[str] = mapped_column(String, unique=True, nullable=False) + title: Mapped[str | None] = mapped_column(String, nullable=True) + image: Mapped[str | None] = mapped_column(String, nullable=True) + text: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=func.now(), nullable=False + ) + + +class LastSentNews(Base): + __tablename__ = "last_sent_news" + + chat_id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + last_news_id: Mapped[int] = mapped_column( + Integer, nullable=True, default=0 + ) diff --git a/requirements.txt b/requirements.txt index ab8e9a0..9c4da35 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ anyio==4.12.1 APScheduler==3.11.2 +asyncpg==0.31.0 attrs==25.4.0 Automat==25.4.16 black==26.1.0 @@ -13,6 +14,7 @@ cssselect==1.3.0 defusedxml==0.7.1 filelock==3.20.3 flake8==7.3.0 +greenlet==3.3.1 h11==0.16.0 httpcore==1.0.9 httpx==0.28.1 @@ -33,6 +35,7 @@ pathspec==1.0.4 platformdirs==4.5.1 pluggy==1.6.0 Protego==0.5.0 +psycopg2-binary==2.9.11 pyasn1==0.6.2 pyasn1_modules==0.4.2 pycodestyle==2.14.0 @@ -51,6 +54,7 @@ requests-file==3.0.1 schedule==1.2.2 Scrapy==2.14.1 service-identity==24.2.0 +SQLAlchemy==2.0.46 tldextract==5.3.1 Twisted==25.5.0 typing_extensions==4.15.0 diff --git a/satire_pulp_parser/spiders/satire_pulp.py b/satire_pulp_parser/spiders/satire_pulp.py index 435170e..b4ab849 100644 --- a/satire_pulp_parser/spiders/satire_pulp.py +++ b/satire_pulp_parser/spiders/satire_pulp.py @@ -1,12 +1,9 @@ import scrapy -from storage import init_db, is_news_exists, save_news +from db_sync import SessionLocal +from spider_storage import is_news_exists, save_news class SatirePulpSpider(scrapy.Spider): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - init_db() - name = "satire_pulp" allowed_domains = ["panorama.pub"] start_urls = ["https://panorama.pub"] @@ -15,9 +12,10 @@ def parse(self, response): news_links = response.css("div.shrink-0 li a::attr(href)").getall() for link in news_links: full_link = response.urljoin(link) - if is_news_exists(full_link): - self.logger.info(f"Новость уже есть: {full_link}") - continue + with SessionLocal() as session: + if is_news_exists(full_link, session): + self.logger.info(f"Новость уже есть: {full_link}") + continue yield scrapy.Request(url=full_link, callback=self.parse_news) @@ -32,7 +30,8 @@ def parse_news(self, response): else: image = None - save_news(response.url, final_title, image, final_text) + with SessionLocal() as session: + save_news(response.url, final_title, image, final_text, session) yield { "title": title, "text": final_text, diff --git a/scheduler.py b/scheduler.py index 6a6dfb7..b7b6b32 100644 --- a/scheduler.py +++ b/scheduler.py @@ -26,11 +26,12 @@ def run_spider(): def run_scheduler(): + """Запуск планировщика, паук запускаеется каждые 20 минут""" scheduler = BlockingScheduler() try: logger.info("...Планировщик запущен...") run_spider() - scheduler.add_job(run_spider, "interval", minutes=30) + scheduler.add_job(run_spider, "interval", minutes=20) scheduler.start() except Exception as e: logger.error(f"Ошибкка в планировщике: {e}") diff --git a/spider_storage.py b/spider_storage.py new file mode 100644 index 0000000..2e9bfc4 --- /dev/null +++ b/spider_storage.py @@ -0,0 +1,27 @@ +import logging + +from models import News +from sqlalchemy.exc import SQLAlchemyError + +logger = logging.getLogger(__name__) + + +def is_news_exists(url: str, session): + """Проверка новости в базе по URL""" + try: + news = session.query(News).filter(News.url == url) + return news.first() is not None + except SQLAlchemyError as e: + logger.error(f"Ошибка проверки новости в базе: {e}") + raise + + +def save_news(url: str, title: str, image: str, text: str, session): + """Созранение новости в базе""" + try: + news = News(url=url, title=title, image=image, text=text) + session.add(news) + session.commit() + except SQLAlchemyError as e: + logger.error(f"Ошибка сохранения новости в бд: {e}") + raise diff --git a/storage.py b/storage.py deleted file mode 100644 index 9952c1d..0000000 --- a/storage.py +++ /dev/null @@ -1,96 +0,0 @@ -import sqlite3 -from datetime import datetime, timezone - -DB_NAME = "satire_pulp.db" - - -def get_connection(): - return sqlite3.connect(DB_NAME) - - -def init_db(): - with get_connection() as conn: - conn.execute(""" - CREATE TABLE IF NOT EXISTS news ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - url TEXT UNIQUE, - title TEXT, - image TEXT, - text TEXT, - create_at TEXT - ) -""") - conn.execute(""" - CREATE TABLE IF NOT EXISTS last_sent_news ( - chat_id INTEGER PRIMARY KEY, - last_news_id INTEGER - ) -""") - conn.commit() - - -def is_news_exists(url): - with get_connection() as conn: - cursor = conn.execute( - """ - SELECT 1 FROM news WHERE url = ? -""", - (url,), - ) - return cursor.fetchone() is not None - - -def save_news(url, title, image, text): - with get_connection() as conn: - conn.execute( - """ - INSERT INTO news (url, title, image, text, create_at) VALUES (?, ?, ?, ?, ?) -""", - (url, title, image, text, datetime.now(timezone.utc).isoformat()), - ) - conn.commit() - - -def get_news_after_id(last_id, n=10): - with get_connection() as conn: - cursor = conn.execute( - """ - SELECT id, title, image, text, url FROM news WHERE id > ? ORDER BY id DESC LIMIT ? -""", - (last_id, n), - ) - return cursor.fetchall()[::-1] - - -def get_last_sent_id(chat_id): - with get_connection() as conn: - cursor = conn.execute( - """ - SELECT last_news_id FROM last_sent_news WHERE chat_id = ? -""", - (chat_id,), - ) - row = cursor.fetchone() - if row: - return row[0] - else: - return 0 - - -def save_last_sent_news_id(chat_id, last_id): - with get_connection() as conn: - conn.execute( - """ - INSERT INTO last_sent_news (chat_id, last_news_id) VALUES (?, ?) - ON CONFLICT(chat_id) DO UPDATE SET last_news_id = excluded.last_news_id""", - (chat_id, last_id), - ) - conn.commit() - - -def get_all_users(): - with get_connection() as conn: - cursor = conn.execute(""" - SELECT chat_id FROM last_sent_news -""") - return [row[0] for row in cursor.fetchall()] From 0581c3f65ee8cc46a0ae7e05d494686799f2738d Mon Sep 17 00:00:00 2001 From: Dmitry Titenkov Date: Wed, 11 Feb 2026 16:36:25 +0300 Subject: [PATCH 18/27] =?UTF-8?q?=D0=91=D0=B0=D0=B7=D0=B0=20PostgreSQL,=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=BF=D1=83=D1=81=D0=BA=20=D0=B2=20Docker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- spider_storage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spider_storage.py b/spider_storage.py index 2e9bfc4..9eb3ecd 100644 --- a/spider_storage.py +++ b/spider_storage.py @@ -17,7 +17,7 @@ def is_news_exists(url: str, session): def save_news(url: str, title: str, image: str, text: str, session): - """Созранение новости в базе""" + """Созранение новости в базе.""" try: news = News(url=url, title=title, image=image, text=text) session.add(news) From c58ea845dffad8095a20de447f8ddd760040d3b4 Mon Sep 17 00:00:00 2001 From: Dmitry Titenkov Date: Wed, 11 Feb 2026 16:36:42 +0300 Subject: [PATCH 19/27] =?UTF-8?q?=D0=91=D0=B0=D0=B7=D0=B0=20PostgreSQL,=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=BF=D1=83=D1=81=D0=BA=20=D0=B2=20Docker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- spider_storage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spider_storage.py b/spider_storage.py index 9eb3ecd..2e9bfc4 100644 --- a/spider_storage.py +++ b/spider_storage.py @@ -17,7 +17,7 @@ def is_news_exists(url: str, session): def save_news(url: str, title: str, image: str, text: str, session): - """Созранение новости в базе.""" + """Созранение новости в базе""" try: news = News(url=url, title=title, image=image, text=text) session.add(news) From c7dc0cead481e68d6fa86e7dbdf94d9300854b4a Mon Sep 17 00:00:00 2001 From: Dmitry Titenkov Date: Thu, 12 Feb 2026 12:48:30 +0300 Subject: [PATCH 20/27] refactoring --- Dockerfile | 2 +- bot/__init__.py | 0 bot_storage.py => bot/bot_storage.py | 2 +- bot.py => bot/handlers.py | 49 +---------------- bot/sender.py | 55 +++++++++++++++++++ db/__init__.py | 0 db_async.py => db/db_async.py | 5 +- db_sync.py => db/db_sync.py | 5 +- init_db.py => db/init_db.py | 4 +- models.py => db/models.py | 0 docker-compose.yml | 8 +-- main.py | 2 +- satire_pulp_parser/items.py | 14 ++--- satire_pulp_parser/pipelines.py | 24 +++++++- satire_pulp_parser/settings.py | 10 ++-- .../spider_storage.py | 3 +- satire_pulp_parser/spiders/satire_pulp.py | 17 +++--- scheduler/__init__.py | 0 scheduler.py => scheduler/scheduler.py | 0 19 files changed, 115 insertions(+), 85 deletions(-) create mode 100644 bot/__init__.py rename bot_storage.py => bot/bot_storage.py (98%) rename bot.py => bot/handlers.py (77%) create mode 100644 bot/sender.py create mode 100644 db/__init__.py rename db_async.py => db/db_async.py (64%) rename db_sync.py => db/db_sync.py (64%) rename init_db.py => db/init_db.py (82%) rename models.py => db/models.py (100%) rename spider_storage.py => satire_pulp_parser/spider_storage.py (93%) create mode 100644 scheduler/__init__.py rename scheduler.py => scheduler/scheduler.py (100%) diff --git a/Dockerfile b/Dockerfile index 91edc3d..423d0b2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,6 +3,6 @@ LABEL maintainer="Dmitry Titenkov " LABEL version="1.0" LABEL description="Satire Pulp parser" WORKDIR /app -COPY requirements.txt /app +COPY requirements.txt . RUN pip3 install -r /app/requirements.txt --no-cache-dir COPY . . diff --git a/bot/__init__.py b/bot/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot_storage.py b/bot/bot_storage.py similarity index 98% rename from bot_storage.py rename to bot/bot_storage.py index 5ba4e5d..ef23f1e 100644 --- a/bot_storage.py +++ b/bot/bot_storage.py @@ -1,6 +1,6 @@ import logging -from models import LastSentNews, News +from db.models import LastSentNews, News from sqlalchemy import select from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.ext.asyncio import AsyncSession diff --git a/bot.py b/bot/handlers.py similarity index 77% rename from bot.py rename to bot/handlers.py index b696c54..a21d441 100644 --- a/bot.py +++ b/bot/handlers.py @@ -1,13 +1,14 @@ import logging -from bot_storage import ( +from bot.bot_storage import ( get_all_users, get_last_sent_id, get_news_after_id, save_last_sent_news_id, ) +from bot.sender import send_news from config import setup_logger -from db_async import AsyncSessionLocal +from db.db_async import AsyncSessionLocal from dotenv import load_dotenv from telegram import ( BotCommand, @@ -23,50 +24,6 @@ logger = logging.getLogger(__name__) -MAX_CAPTION_LENGTH = 1024 - - -def format_message(title, text): - message = f"*{title}*\n\n{text}\n" - if len(message) > MAX_CAPTION_LENGTH: - message = f"*{title}*\n\n{text[:MAX_CAPTION_LENGTH]} ...✂️\n" - return message - - -async def send_news( - chat_id: int, context: ContextTypes.DEFAULT_TYPE, title, image, text, url -): - message = format_message(title, text) - keyboard = [ - [InlineKeyboardButton("Читать полную версию на сайте", url=url)] - ] - reply_markup = InlineKeyboardMarkup(keyboard) - if image: - try: - await context.bot.send_photo( - chat_id=chat_id, - photo=image, - caption=message, - parse_mode="Markdown", - reply_markup=reply_markup, - ) - logger.info("Новость отправлена с картинкой") - return - except Exception as e: - logger.error( - f"Не удалось отправить фото по ссылке, ошибка: {e}", - ) - try: - await context.bot.send_message( - chat_id, message, parse_mode="Markdown", reply_markup=reply_markup - ) - logger.info(f"Новость '{title[:25]}' отправлена без картинки") - except Exception as e: - logger.error( - f"Не удалось отправить сообщение с новостью '{title[:25]}', ошибка: {e}" - ) - - async def auto_send_news(context: ContextTypes.DEFAULT_TYPE): async with AsyncSessionLocal() as session: users = await get_all_users(session) diff --git a/bot/sender.py b/bot/sender.py new file mode 100644 index 0000000..9cf63c6 --- /dev/null +++ b/bot/sender.py @@ -0,0 +1,55 @@ +import logging + +from config import setup_logger +from dotenv import load_dotenv +from telegram import InlineKeyboardButton, InlineKeyboardMarkup +from telegram.ext import ContextTypes + +load_dotenv() + +setup_logger() +logger = logging.getLogger(__name__) + + +MAX_CAPTION_LENGTH = 1024 + + +def format_message(title, text): + message = f"*{title}*\n\n{text}\n" + if len(message) > MAX_CAPTION_LENGTH: + message = f"*{title}*\n\n{text[:MAX_CAPTION_LENGTH]} ...✂️\n" + return message + + +async def send_news( + chat_id: int, context: ContextTypes.DEFAULT_TYPE, title, image, text, url +): + message = format_message(title, text) + keyboard = [ + [InlineKeyboardButton("Читать полную версию на сайте", url=url)] + ] + reply_markup = InlineKeyboardMarkup(keyboard) + if image: + try: + await context.bot.send_photo( + chat_id=chat_id, + photo=image, + caption=message, + parse_mode="Markdown", + reply_markup=reply_markup, + ) + logger.info("Новость отправлена с картинкой") + return + except Exception as e: + logger.error( + f"Не удалось отправить фото по ссылке, ошибка: {e}", + ) + try: + await context.bot.send_message( + chat_id, message, parse_mode="Markdown", reply_markup=reply_markup + ) + logger.info(f"Новость '{title[:25]}' отправлена без картинки") + except Exception as e: + logger.error( + f"Не удалось отправить сообщение с новостью '{title[:25]}', ошибка: {e}" + ) diff --git a/db/__init__.py b/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/db_async.py b/db/db_async.py similarity index 64% rename from db_async.py rename to db/db_async.py index 5d00ea3..44c6e37 100644 --- a/db_async.py +++ b/db/db_async.py @@ -6,7 +6,10 @@ load_dotenv() -engine = create_async_engine(os.getenv("DATABASE_URL_ASYNC"), echo=False) +DATABASE_URL_ASYNC = os.getenv("DATABASE_URL_ASYNC") + + +engine = create_async_engine(DATABASE_URL_ASYNC, echo=False) AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False) diff --git a/db_sync.py b/db/db_sync.py similarity index 64% rename from db_sync.py rename to db/db_sync.py index 1423a78..4c9c0eb 100644 --- a/db_sync.py +++ b/db/db_sync.py @@ -7,6 +7,9 @@ load_dotenv() -engine = create_engine(os.getenv("DATABASE_URL_SYNC")) +DATABASE_URL_SYNC = os.getenv("DATABASE_URL_SYNC") + + +engine = create_engine(DATABASE_URL_SYNC) SessionLocal = sessionmaker(engine) diff --git a/init_db.py b/db/init_db.py similarity index 82% rename from init_db.py rename to db/init_db.py index 7fe950b..88c230a 100644 --- a/init_db.py +++ b/db/init_db.py @@ -2,9 +2,9 @@ import logging from config import setup_logger -from db_async import engine +from db.db_async import engine +from db.models import Base, LastSentNews, News # noqa from dotenv import load_dotenv -from models import Base, LastSentNews, News # noqa load_dotenv() diff --git a/models.py b/db/models.py similarity index 100% rename from models.py rename to db/models.py diff --git a/docker-compose.yml b/docker-compose.yml index 2827f07..6a380e5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,7 +8,7 @@ services: ports: - "5432:5432" env_file: - - ./.env + - .env bot: build: . @@ -16,18 +16,18 @@ services: depends_on: - db env_file: - - ./.env + - .env command: python3 main.py scheduler: build: . restart: always - command: python3 scheduler.py + command: python3 -m scheduler.scheduler depends_on: - db - bot env_file: - - ./.env + - .env volumes: postgres_data: \ No newline at end of file diff --git a/main.py b/main.py index 306aea6..d2e15e0 100644 --- a/main.py +++ b/main.py @@ -2,7 +2,7 @@ import logging import os -from bot import ( +from bot.handlers import ( auto_send_news, button_handler, help_command, diff --git a/satire_pulp_parser/items.py b/satire_pulp_parser/items.py index f95d9ea..3b19bb9 100644 --- a/satire_pulp_parser/items.py +++ b/satire_pulp_parser/items.py @@ -1,12 +1,8 @@ -# Define here the models for your scraped items -# -# See documentation in: -# https://docs.scrapy.org/en/latest/topics/items.html - import scrapy -class SatirePulpParserItem(scrapy.Item): - # define the fields for your item here like: - # name = scrapy.Field() - pass +class NewsItem(scrapy.Item): + title = scrapy.Field() + text = scrapy.Field() + image = scrapy.Field() + url = scrapy.Field() diff --git a/satire_pulp_parser/pipelines.py b/satire_pulp_parser/pipelines.py index 164045c..c863928 100644 --- a/satire_pulp_parser/pipelines.py +++ b/satire_pulp_parser/pipelines.py @@ -1,6 +1,24 @@ -# from itemadapter import ItemAdapter +import logging +from db.db_sync import SessionLocal +from satire_pulp_parser.items import NewsItem +from satire_pulp_parser.spider_storage import save_news -class SatirePulpParserPipeline: - def process_item(self, item, spider): +logger = logging.getLogger(__name__) + + +class SaveNewsPipeline: + def process_item(self, item: NewsItem, spider): + try: + with SessionLocal() as session: + save_news( + url=item["url"], + title=item["title"], + image=item["image"], + text=item["text"], + session=session, + ) + logger.info("...Новость сохранена...") + except Exception as e: + logger.error(f"Ошибка при сохранении новости: {e}") return item diff --git a/satire_pulp_parser/settings.py b/satire_pulp_parser/settings.py index bd1ddc2..5921b6b 100644 --- a/satire_pulp_parser/settings.py +++ b/satire_pulp_parser/settings.py @@ -7,7 +7,7 @@ # https://docs.scrapy.org/en/latest/topics/downloader-middleware.html # https://docs.scrapy.org/en/latest/topics/spider-middleware.html -BOT_NAME = "satire_pulp_parser" +BOT_NAME = "satire_pulp" SPIDER_MODULES = ["satire_pulp_parser.spiders"] NEWSPIDER_MODULE = "satire_pulp_parser.spiders" @@ -16,7 +16,7 @@ # Crawl responsibly by identifying yourself (and your website) on the user-agent -# USER_AGENT = "satire_pulp_parser (+http://www.yourdomain.com)" +USER_AGENT = "satire_pulp (+http://panorama.pub)" # Obey robots.txt rules ROBOTSTXT_OBEY = True @@ -58,9 +58,9 @@ # Configure item pipelines # See https://docs.scrapy.org/en/latest/topics/item-pipeline.html -# ITEM_PIPELINES = { -# "satire_pulp_parser.pipelines.SatirePulpParserPipeline": 300, -# } +ITEM_PIPELINES = { + "satire_pulp_parser.pipelines.SaveNewsPipeline": 300, +} # Enable and configure the AutoThrottle extension (disabled by default) # See https://docs.scrapy.org/en/latest/topics/autothrottle.html diff --git a/spider_storage.py b/satire_pulp_parser/spider_storage.py similarity index 93% rename from spider_storage.py rename to satire_pulp_parser/spider_storage.py index 2e9bfc4..f84e886 100644 --- a/spider_storage.py +++ b/satire_pulp_parser/spider_storage.py @@ -1,6 +1,6 @@ import logging -from models import News +from db.models import News from sqlalchemy.exc import SQLAlchemyError logger = logging.getLogger(__name__) @@ -23,5 +23,6 @@ def save_news(url: str, title: str, image: str, text: str, session): session.add(news) session.commit() except SQLAlchemyError as e: + session.rollback() logger.error(f"Ошибка сохранения новости в бд: {e}") raise diff --git a/satire_pulp_parser/spiders/satire_pulp.py b/satire_pulp_parser/spiders/satire_pulp.py index b4ab849..700162b 100644 --- a/satire_pulp_parser/spiders/satire_pulp.py +++ b/satire_pulp_parser/spiders/satire_pulp.py @@ -1,6 +1,7 @@ import scrapy -from db_sync import SessionLocal -from spider_storage import is_news_exists, save_news +from db.db_sync import SessionLocal +from satire_pulp_parser.items import NewsItem +from satire_pulp_parser.spider_storage import is_news_exists class SatirePulpSpider(scrapy.Spider): @@ -30,11 +31,7 @@ def parse_news(self, response): else: image = None - with SessionLocal() as session: - save_news(response.url, final_title, image, final_text, session) - yield { - "title": title, - "text": final_text, - "image": image, - "url": response.url, - } + item = NewsItem( + title=final_title, text=final_text, image=image, url=response.url + ) + yield item diff --git a/scheduler/__init__.py b/scheduler/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scheduler.py b/scheduler/scheduler.py similarity index 100% rename from scheduler.py rename to scheduler/scheduler.py From 8d75c082cfe4e8734d8e72914bd65009cd498d8a Mon Sep 17 00:00:00 2001 From: Dmitry Titenkov Date: Mon, 16 Feb 2026 12:51:43 +0300 Subject: [PATCH 21/27] pytest --- requirements.txt | 2 ++ tests/conftest.py | 54 ++++++++++++++++++++++++++++++++++++ tests/test_bot_storage.py | 37 ++++++++++++++++++++++++ tests/test_spider_storage.py | 10 +++++++ 4 files changed, 103 insertions(+) create mode 100644 tests/conftest.py create mode 100644 tests/test_bot_storage.py create mode 100644 tests/test_spider_storage.py diff --git a/requirements.txt b/requirements.txt index 9c4da35..278ff90 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +aiosqlite==0.22.1 anyio==4.12.1 APScheduler==3.11.2 asyncpg==0.31.0 @@ -45,6 +46,7 @@ pyflakes==3.4.0 Pygments==2.19.2 pyOpenSSL==25.3.0 pytest==9.0.2 +pytest-asyncio==1.3.0 python-dotenv==1.2.1 python-telegram-bot==22.6 pytokens==0.4.0 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..2699173 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,54 @@ +import pytest +import pytest_asyncio +from sqlalchemy import create_engine +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker +from sqlalchemy.orm import sessionmaker + +from db.models import Base, News + + +DATABASE_SYNC_URL = "sqlite:///:memory:" +DATABASE_ASYNC_URL = "sqlite+aiosqlite:///:memory:" + + +@pytest.fixture +def session(): + engine = create_engine(DATABASE_SYNC_URL, echo=False) + Base.metadata.create_all(engine) + Session = sessionmaker(engine) + session = Session() + yield session + session.close() + + +@pytest_asyncio.fixture +async def async_session(): + engine = create_async_engine(DATABASE_ASYNC_URL, echo=False) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + AsyncSession = async_sessionmaker(engine, expire_on_commit=False) + async with AsyncSession() as async_session: + yield async_session + await engine.dispose() + + +@pytest.fixture +def news(): + test_news = { + 'url': 'https://panorama.pub/test-news', + 'title': 'Test Title', + 'text': 'Test Text', + 'image': None + } + return test_news + + +@pytest_asyncio.fixture +async def news_list(async_session): + first_news = News(url="https://panorama.pub/test-news_1", title="Test Title 1", image=None, text="Ttest Text_1") + second_news = News(url="https://panorama.pub/test-news_2", title="Test Title 2", image=None, text="Ttest Text_2") + + async_session.add_all([first_news, second_news]) + await async_session.commit() + + return [first_news, second_news] diff --git a/tests/test_bot_storage.py b/tests/test_bot_storage.py new file mode 100644 index 0000000..9d13804 --- /dev/null +++ b/tests/test_bot_storage.py @@ -0,0 +1,37 @@ +import pytest + +from bot.bot_storage import get_last_sent_id, save_last_sent_news_id, get_news_after_id, get_all_users + + +@pytest.mark.asyncio +async def test_get_last_sent_id(async_session): + """Проверка id последней отправленой новости""" + chat_id = 100 + + last_id = await get_last_sent_id(chat_id, async_session) + assert last_id == 0, "id последней отпрвленной новости должен быть равен 0, если нет отправленных новостей" + + await save_last_sent_news_id(chat_id=chat_id, last_id=10, session=async_session) + last_id = await get_last_sent_id(chat_id, async_session) + assert last_id == 10, "id последней отпрвленной новости должен быть равен 10" + + +@pytest.mark.asyncio +async def test_get_news_after_id(async_session, news_list): + """Получение новостей""" + news = await get_news_after_id(last_id=0, session=async_session) + assert len(news) == 2, "Должно быть получено 2 новости" + assert news[0].title == "Test Title 1", "Заголовок первой новости должен быть 'Test Title 1'" + assert news[1].title == "Test Title 2", "Заголовок второй новости должен быть 'Test Title 2'" + + +@pytest.mark.asyncio +async def test_get_all_users(async_session): + """Получение пользователей""" + await save_last_sent_news_id(chat_id=111, last_id=11, session=async_session) + await save_last_sent_news_id(chat_id=222, last_id=22, session=async_session) + + users = await get_all_users(async_session) + assert len(users) == 2, "Должно быть 2 пользователя в базе" + assert users[0] == 111, "id первого юзера должен быть 111" + assert users[1] == 222, "id второго юзеера должен быть 222" diff --git a/tests/test_spider_storage.py b/tests/test_spider_storage.py new file mode 100644 index 0000000..6cff57c --- /dev/null +++ b/tests/test_spider_storage.py @@ -0,0 +1,10 @@ +from satire_pulp_parser.spider_storage import is_news_exists, save_news + + +def test_save_and_check_news(session, news): + """Проверяет что новостей нет в базе, + сохранение новости + и новость появилась в базе после сохранения.""" + assert not is_news_exists(news['url'], session), "Перед сохраением новой новости её не должно быть в базе" + save_news(news["url"], news["title"], news['image'], news["text"], session=session) + assert is_news_exists(news["url"], session), "Новость должна появиться в базе после сохранения" From 6ee7e1d4a47aacf0e35ebdba4089fc495918a174 Mon Sep 17 00:00:00 2001 From: Dmitry Titenkov Date: Mon, 16 Feb 2026 13:00:11 +0300 Subject: [PATCH 22/27] pytest --- tests/conftest.py | 28 ++++++++++++++++---------- tests/test_bot_storage.py | 38 ++++++++++++++++++++++++++---------- tests/test_spider_storage.py | 16 ++++++++++++--- 3 files changed, 59 insertions(+), 23 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 2699173..158038b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,12 +1,10 @@ import pytest import pytest_asyncio +from db.models import Base, News from sqlalchemy import create_engine -from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlalchemy.orm import sessionmaker -from db.models import Base, News - - DATABASE_SYNC_URL = "sqlite:///:memory:" DATABASE_ASYNC_URL = "sqlite+aiosqlite:///:memory:" @@ -35,18 +33,28 @@ async def async_session(): @pytest.fixture def news(): test_news = { - 'url': 'https://panorama.pub/test-news', - 'title': 'Test Title', - 'text': 'Test Text', - 'image': None + "url": "https://panorama.pub/test-news", + "title": "Test Title", + "text": "Test Text", + "image": None, } return test_news @pytest_asyncio.fixture async def news_list(async_session): - first_news = News(url="https://panorama.pub/test-news_1", title="Test Title 1", image=None, text="Ttest Text_1") - second_news = News(url="https://panorama.pub/test-news_2", title="Test Title 2", image=None, text="Ttest Text_2") + first_news = News( + url="https://panorama.pub/test-news_1", + title="Test Title 1", + image=None, + text="Ttest Text_1", + ) + second_news = News( + url="https://panorama.pub/test-news_2", + title="Test Title 2", + image=None, + text="Ttest Text_2", + ) async_session.add_all([first_news, second_news]) await async_session.commit() diff --git a/tests/test_bot_storage.py b/tests/test_bot_storage.py index 9d13804..032ad6f 100644 --- a/tests/test_bot_storage.py +++ b/tests/test_bot_storage.py @@ -1,6 +1,10 @@ import pytest - -from bot.bot_storage import get_last_sent_id, save_last_sent_news_id, get_news_after_id, get_all_users +from bot.bot_storage import ( + get_all_users, + get_last_sent_id, + get_news_after_id, + save_last_sent_news_id, +) @pytest.mark.asyncio @@ -9,11 +13,17 @@ async def test_get_last_sent_id(async_session): chat_id = 100 last_id = await get_last_sent_id(chat_id, async_session) - assert last_id == 0, "id последней отпрвленной новости должен быть равен 0, если нет отправленных новостей" + assert ( + last_id == 0 + ), "id последней отпрвленной новости должен быть равен 0, если нет отправленных новостей" - await save_last_sent_news_id(chat_id=chat_id, last_id=10, session=async_session) + await save_last_sent_news_id( + chat_id=chat_id, last_id=10, session=async_session + ) last_id = await get_last_sent_id(chat_id, async_session) - assert last_id == 10, "id последней отпрвленной новости должен быть равен 10" + assert ( + last_id == 10 + ), "id последней отпрвленной новости должен быть равен 10" @pytest.mark.asyncio @@ -21,17 +31,25 @@ async def test_get_news_after_id(async_session, news_list): """Получение новостей""" news = await get_news_after_id(last_id=0, session=async_session) assert len(news) == 2, "Должно быть получено 2 новости" - assert news[0].title == "Test Title 1", "Заголовок первой новости должен быть 'Test Title 1'" - assert news[1].title == "Test Title 2", "Заголовок второй новости должен быть 'Test Title 2'" + assert ( + news[0].title == "Test Title 1" + ), "Заголовок первой новости должен быть 'Test Title 1'" + assert ( + news[1].title == "Test Title 2" + ), "Заголовок второй новости должен быть 'Test Title 2'" @pytest.mark.asyncio async def test_get_all_users(async_session): """Получение пользователей""" - await save_last_sent_news_id(chat_id=111, last_id=11, session=async_session) - await save_last_sent_news_id(chat_id=222, last_id=22, session=async_session) + await save_last_sent_news_id( + chat_id=111, last_id=11, session=async_session + ) + await save_last_sent_news_id( + chat_id=222, last_id=22, session=async_session + ) users = await get_all_users(async_session) assert len(users) == 2, "Должно быть 2 пользователя в базе" assert users[0] == 111, "id первого юзера должен быть 111" - assert users[1] == 222, "id второго юзеера должен быть 222" + assert users[1] == 222, "id второго юзеера должен быть 222" diff --git a/tests/test_spider_storage.py b/tests/test_spider_storage.py index 6cff57c..fa3817e 100644 --- a/tests/test_spider_storage.py +++ b/tests/test_spider_storage.py @@ -5,6 +5,16 @@ def test_save_and_check_news(session, news): """Проверяет что новостей нет в базе, сохранение новости и новость появилась в базе после сохранения.""" - assert not is_news_exists(news['url'], session), "Перед сохраением новой новости её не должно быть в базе" - save_news(news["url"], news["title"], news['image'], news["text"], session=session) - assert is_news_exists(news["url"], session), "Новость должна появиться в базе после сохранения" + assert not is_news_exists( + news["url"], session + ), "Перед сохраением новой новости её не должно быть в базе" + save_news( + news["url"], + news["title"], + news["image"], + news["text"], + session=session, + ) + assert is_news_exists( + news["url"], session + ), "Новость должна появиться в базе после сохранения" From 73e3294383a0b706613e8f70dcac1b400d61fae4 Mon Sep 17 00:00:00 2001 From: Dmitry Titenkov Date: Mon, 16 Feb 2026 13:55:47 +0300 Subject: [PATCH 23/27] ci_pytest --- .github/workflows/main.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 389ecf4..3cdbb39 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -30,3 +30,25 @@ jobs: - name: Flake8 Check run: flake8 . + + tests: + name: Pytest + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: "3.11" + + - name: Upgrade pip + run: python -m pip install --upgrade pip + + - name: Install Dependencies + run: pip install -r requirements.txt + + - name: Run Pytest + run: pytest -v \ No newline at end of file From be6957eeee89b4a9075624e3fe47ca69be92e733 Mon Sep 17 00:00:00 2001 From: Dmitry Titenkov Date: Mon, 16 Feb 2026 14:17:14 +0300 Subject: [PATCH 24/27] ci_push_to_dockerhub --- .github/workflows/main.yml | 62 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 60 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 3cdbb39..3bec1c5 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,6 +1,10 @@ name: Satire Pulp parser -on: [push] +on: + push: + branches: + - "**" + pull_request: jobs: lint: @@ -51,4 +55,58 @@ jobs: run: pip install -r requirements.txt - name: Run Pytest - run: pytest -v \ No newline at end of file + run: pytest -v + + push_branch_dev_to_docker_hub: + name: Build and Push Docker(dev) + runs-on: ubuntu-latest + needs: lint + + if: github.ref == 'refs/heads/dev' + + steps: + - name: Check out the repo + uses: actions/checkout@v4 + + - name: Setup Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Push to Docker Hub + uses: docker/build-push-action@v5 + with: + push: true + tags: | + dmsn/satire_pulp_parser:dev + + push_branch_main_to_docker_hub: + name: Build and Push Docker(prod) + runs-on: ubuntu-latest + needs: lint + + if: github.ref == 'refs/heads/main' + + steps: + - name: Check out the repo + uses: actions/checkout@v4 + + - name: Setup Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Push to Docker Hub + uses: docker/build-push-action@v5 + with: + push: true + tags: | + dmsn/satire_pulp_parser:prod From 8c559423ffe2de9cacd02579654dfbb61013e359 Mon Sep 17 00:00:00 2001 From: Dmitry Titenkov Date: Mon, 16 Feb 2026 15:00:05 +0300 Subject: [PATCH 25/27] docker-compose --- docker-compose.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 6a380e5..0ab8446 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,7 +11,7 @@ services: - .env bot: - build: . + image: dmsn/satire_pulp_parser:prod restart: always depends_on: - db @@ -20,7 +20,7 @@ services: command: python3 main.py scheduler: - build: . + image: dmsn/satire_pulp_parser:prod restart: always command: python3 -m scheduler.scheduler depends_on: From dca415103d832f5008ea2d8f7e0f1a9d51dc4449 Mon Sep 17 00:00:00 2001 From: Dmitry Titenkov Date: Mon, 16 Feb 2026 15:58:42 +0300 Subject: [PATCH 26/27] readme.md --- README.md | 172 +++++++++++++++++++++++++++++++++++++++++++++++++--- env.example | 8 +++ 2 files changed, 172 insertions(+), 8 deletions(-) create mode 100644 env.example diff --git a/README.md b/README.md index 0af2d79..5533682 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,164 @@ -# satire_pulp_parser -Парсер с сайта сатирических новостей [Панорама](https://panorama.pub/ "Перейти") - -![Satire Pulp parser](https://github.com/dmsnback/satire_pulp_parser/actions/workflows/main.yml/badge.svg) -![Python](https://img.shields.io/badge/python-3.11-blue) -![Tests](https://img.shields.io/badge/tests-pytest-brightgreen) -![Black](https://img.shields.io/badge/code%20style-black-000000) -![License](https://img.shields.io/badge/license-MIT-green) + + +## Satire Pulp Parser + +![Satire Pulp parser](https://github.com/dmsnback/satire_pulp_parser/actions/workflows/main.yml/badge.svg) ![Python](https://img.shields.io/badge/python-3.11-blue) ![Tests](https://img.shields.io/badge/tests-pytest-brightgreen) ![Black](https://img.shields.io/badge/code%20style-black-000000) ![License](https://img.shields.io/badge/license-MIT-green) + + +- [Описание](#Описание) +- [Технологии](#Технологии) +- [Тестирование](#Тестирование) +- [Шаблон заполнения .env-файла](#Шаблон) +- [Запуск проекта](#Запуск) +- [Автор](#Автор) + + + +### Описание + +Проект представляет собой парсер сатирических новостей с сайта [Панорама](https://panorama.pub/ "Перейти") и Telegram-бот для автоматической рассылки новых публикаций пользователям. + +**Возможности:** +```md + - Парсинг новостей с сайта panorama.pub + - Сохранение новостей в PostgreSQL + - Автоматическая рассылка новых новостей через Telegram-бот + - Планировщик запуска парсера каждые 20 минут + - Асинхронная работа бота с данными +``` + +Парсер написан с использованием **Scrapy**, **SQLAlchemy**, **PostgreSQL** и **Python Telegram Bot** + +В проекте настроен **CI pipeline** с использованием **GitHub Actions**: + +```md +- Автоматическая проверка кода (black, isort, flake8) +- Запуск unit-тестов (`pytest`) +- Сборка Docker-образа +- Публикация образа в **Docker Hub** при пуше в соответствующие ветки +``` + +```md +Проект адаптирован для использования **PostgreSQL** и развёртывания в контейнерах **Docker**. +``` + +> [Вернуться в начало](#Начало) + + + +### Технологии + +[![Python](https://img.shields.io/badge/Python-1000?style=for-the-badge&logo=python&logoColor=ffffff&labelColor=000000&color=000000)](https://www.python.org) +[![Scrapy](https://img.shields.io/badge/Scrapy-1000?style=for-the-badge&logo=scrapy&logoColor=ffffff&labelColor=000000&color=000000)](https://docs.scrapy.org/en/latest/index.html) +[![python_telegram_bot](https://img.shields.io/badge/python_telegram_bot-1000?style=for-the-badge&logo=telegram&logoColor=ffffff&labelColor=000000&color=000000)](https://docs.python-telegram-bot.org/en/stable/index.html) +[![Postgres](https://img.shields.io/badge/Postgres-1000?style=for-the-badge&logo=postgresql&logoColor=ffffff&labelColor=000000&color=000000)](https://www.postgresql.org) +[![SQLAlchemy](https://img.shields.io/badge/SQLAlchemy-1000?style=for-the-badge&logo=sqlalchemy&logoColor=ffffff&labelColor=000000&color=000000)](https://www.sqlalchemy.org) +[![Docker](https://img.shields.io/badge/Docker-1000?style=for-the-badge&logo=docker&logoColor=ffffff&labelColor=000000&color=000000)](https://www.docker.com) +[![Pytest](https://img.shields.io/badge/Pytest-1000?style=for-the-badge&logo=pytest&logoColor=ffffff&labelColor=000000&color=000000)](https://docs.pytest.org/en/stable/index.htmlc) +[![GitHub Actions](https://img.shields.io/badge/github%20actions-%232671E5.svg?style=for-the-badge&logo=githubactions&logoColor=ffffff&labelColor=000000&color=000000)](https://github.com/features/actions) + +> [Вернуться в начало](#Начало) + + + +### Тестирование + +В проекте реализованы **unit-тесты** с использованием `pytest`. + +Запуск тестов локально: + +```python +pytest -v +``` + +> [Вернуться в начало](#Начало) + + + +### Шаблон заполнения .env-файла + +> `env.example` с дефолтными значениями расположен в корневой папке + +```python +TELEGRAM_TOKEN=1234567890:Telegram-Token # Токен Telegram бота +DATABASE_URL_SYNC = postgresql+psycopg2://postgres:postgres@db:5432/satire_pulp_db # Указываем адрес БД (Синхронная версия) +DATABASE_URL_ASYNC=postgresql+asyncpg://postgres:postgres@db:5432/satire_pulp_db # Указываем адрес БД (Асинхронная версия) +POSTGRES_DB = satire_pulp_db # Имя базы дданных +POSTGRES_USER = postgres # Имя юзера PostgreSQL +POSTGRES_PASSWORD = yourpassword # Пароль юзера PostgreSQL +POSTGRES_HOST=db # Имя сервиса PostgreSQL в docker-compose +POSTGRES_PORT=5432 # Порт PostgreSQL внутри контейнера +``` + +> [Вернуться в начало](#Начало) + + + +### Запуск проекта на локальной машине + +- Склонируйте репозиторий + +```python +git clone git@github.com:dmsnback/satire_pulp_parser.git +``` + +- Установите и активируйте виртуальное окружение + +```python +python3 -m venv venv +``` + +Для `Windows` + +```python +source venv/Scripts/activate +``` + +Для `Mac/Linux` + +```python +source venv/bin/activate +``` + +- Установите зависимости из файла +`requirements.txt` + +```python +python3 -m pip install --upgrade pip +``` + +```python +pip install -r requirements.txt +``` + +- Запускаем Docker контейнеры (db, bot) + +```python +docker-compose up -d db bot +``` + +- Создаём таблицы в БД + +```python +docker-compose exec bot python -m db.init_db +``` + +- Перезапускаем Docker контейнеры + +```python +docker-compose up -d +``` + +- После запуска запустите бота командой ```/start``` + +> Команда ```/show_news``` пришлёт последние 10 новостей из базы, если они ещё не были отправлены, далее бот будет присылать только новые новости, которые появятся на сайте. + +> [Вернуться в начало](#Начало) + + + +### Автор + +- [Титенков Дмитрий](https://github.com/dmsnback) + +> [Вернуться в начало](#Начало) diff --git a/env.example b/env.example new file mode 100644 index 0000000..b26e7c3 --- /dev/null +++ b/env.example @@ -0,0 +1,8 @@ +TELEGRAM_TOKEN=1234567890:Telegram-Token +DATABASE_URL_SYNC=postgresql+psycopg2://postgres:postgres@db:5432/satire_pulp_db +DATABASE_URL_ASYNC=postgresql+asyncpg://postgres:postgres@db:5432/satire_pulp_db +POSTGRES_DB=satire_pulp_db +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres +POSTGRES_HOST=db +POSTGRES_PORT=5432 \ No newline at end of file From c11662b3671baee24d020192b788369e05c1aa8d Mon Sep 17 00:00:00 2001 From: Dmitry Titenkov Date: Mon, 16 Feb 2026 16:26:39 +0300 Subject: [PATCH 27/27] readme.md --- README.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 5533682..1ebdad8 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Проект представляет собой парсер сатирических новостей с сайта [Панорама](https://panorama.pub/ "Перейти") и Telegram-бот для автоматической рассылки новых публикаций пользователям. **Возможности:** + ```md - Парсинг новостей с сайта panorama.pub - Сохранение новостей в PostgreSQL @@ -32,10 +33,10 @@ В проекте настроен **CI pipeline** с использованием **GitHub Actions**: ```md -- Автоматическая проверка кода (black, isort, flake8) -- Запуск unit-тестов (`pytest`) -- Сборка Docker-образа -- Публикация образа в **Docker Hub** при пуше в соответствующие ветки + - Автоматическая проверка кода (black, isort, flake8) + - Запуск unit-тестов (`pytest`) + - Сборка Docker-образа + - Публикация образа в **Docker Hub** при пуше в соответствующие ветки ``` ```md @@ -94,7 +95,7 @@ POSTGRES_PORT=5432 # Порт PostgreSQL внутри контейнера -### Запуск проекта на локальной машине +### Запуск проекта - Склонируйте репозиторий