Scrapy з asyncio: Telegram як тригер для асинхронного скрейпінгу

💡 Усі статті, обговорення, новини про Mobile — в одному місці. Приєднуйтесь до Mobile спільноти!

На мою думку, Scrapy — це досить недооцінений фреймворк, який часто вважають занадто складним, особливо для створення невеликих сканерів. Проте чим більше ви ним користуєтеся, тим менше у вас виникатиме бажання переходити на щось інше.

⚠️ Важливо: усе, що показано нижче — виключно в навчальних цілях.

Не ламайте сайти, не перевантажуйте сервери і поважайте працю інших. Хтось ці сайти створював, і для когось вони мають велику цінність.

(Окрім, звісно, сайтів з оркостану.)

Проблема

Scrapy під капотом використовує Twisted — асинхронний фреймворк, який не сумісний з asyncio «із коробки». Через це виникає чимало плутанини та обмежень, особливо якщо ви хочете інтегрувати Scrapy з іншими сучасними async-інструментами, як-от asyncio, aiohttp, чи будь-який Telegram-бот на aiogram.

У реальних проектах це часто призводить до обхідних рішень:

  • створення окремих сервісів,
  • запуск Scrapy як окремого процесу,
  • складні тригери для запуску.

Все це ускладнює розробку — особливо якщо проєкт невеликий і не потребує складної архітектури.

Що я пропоную

Нижче я покажу, як на мою думку, можна зробити простіше: запускати Scrapy за допомогою Telegram-бота, який реагує на команди користувача — наприклад, щоб у реальному часі сканувати акції в супермаркетах.

Ми обійдемо типові проблеми взаємодії asyncio з Scrapy і отримаємо зручний, легкий у використанні інтерфейс запуску скрейпінгу без додаткових процесів і складної логіки.

Ну що спробуємо

У документації scrapy зазначено, що для сумісності з asyncio потрібно змінити reactor на:

'twisted.internet.asyncioreactor.AsyncioSelectorReactor'

Зі сторони нашого бота — це буде виглядати як:

from scrapy.utils.reactor import install_reactor
install_reactor("twisted.internet.asyncioreactor.AsyncioSelectorReactor")

Telegram-бот

Я не зосереджуюсь тут на реалізації Telegram-бота. Якщо вам цікаво побачити повний код — напишіть мені. З радістю поділюсь ним у відповідь на донат на один з актуальних зборів (DOU або Стерненку).

Ось базовий код запуску бота:

class PriceBot:
    """Main bot class."""

    def __init__(self):
        self.app = ApplicationBuilder().token(PriceBotConfig.TELEGRAM_API_TOKEN).build()
        self._setup_handlers()

    def _setup_handlers(self):
        """Setup conversation handlers."""
        conv_handler = ConversationHandler(
            entry_points=[CommandHandler("start", PriceBotHandlers.start)],
            states={
                SELECT_SHOP: [MessageHandler(filters.TEXT & ~filters.COMMAND, PriceBotHandlers.select_shop)],
                SELECT_CITY: [MessageHandler(filters.TEXT & ~filters.COMMAND, PriceBotHandlers.select_city)],
                SELECT_PROMOTION: [MessageHandler(filters.TEXT & ~filters.COMMAND, PriceBotHandlers.select_promotion)],
                SELECT_SHOPS: [MessageHandler(filters.TEXT & ~filters.COMMAND, PriceBotHandlers.select_shops)],
            },
            fallbacks=[CommandHandler("start", PriceBotHandlers.start)],
        )

        self.app.add_handler(conv_handler)

    def run(self):
        """Start the bot."""
        self.app.run_polling()

def main():
    """Main entry point."""
    if not PriceBotConfig.TELEGRAM_API_TOKEN:
        raise ValueError("TELEGRAM_API_TOKEN environment variable is required")

    bot = PriceBot()
    bot.run()

if __name__ == "__main__":
    main()

Команди select_shop, select_city, select_promotion — це лише логіка формування URL і параметрів. Більш цікавіше має бути запуск спайдера, який ми розглянемо нижче.

Запуск Scrapy через asyncio (Telegram)

Після збору всіх параметрів (наприклад, вибору магазину/міст) — запускається сам спайдер:

await asyncio.wait_for(
        run_spider_async(shops, file_path, base_url, shop_name), timeout=60
    )

⚙️ Функція run_spider_async

Це серце інтеграції Scrapy та asyncio:

from scrapy.crawler import CrawlerRunner
from scrapy.utils.log import configure_logging
from scrapy.utils.defer import deferred_to_future

SPIDERS = {
    "АТБ": ATBSpider,
    "Сільпо": SilpoSpider,   
    "Metro": MetroSpider
}

async def run_spider_async(shops, file_path, base_url=None, shop="АТБ"):
    configure_logging({"LOG_FORMAT": "%(levelname)s: %(message)s"})

    runner = CrawlerRunner(settings={
        "USER_AGENT": None,
        "DOWNLOAD_HANDLERS": {
            "http": "scrapy_impersonate.ImpersonateDownloadHandler",
            "https": "scrapy_impersonate.ImpersonateDownloadHandler",
        },
        "LOG_LEVEL": "INFO",
        "FEEDS": {
            file_path: {
                "format": "csv",
                "overwrite": True,
                "encoding": "utf8",
            }
        }
    })

    crawl_deferred = runner.crawl(SPIDERS[shop], shops=shops, base_url=base_url)
    await deferred_to_future(crawl_deferred)

Тут потрібно звернути увагу на deferred_to_future.

Ну і один із spiders

Нічого надлишкового, лише те, що потрібно (і зверніть увагу, що нічого, ну добре майже нічого, не має, що вказує на asyncio):

from random import choice

from scrapy import Spider, Request

class ATBSpider(Spider):
    name = "atb_spider"

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.shops = kwargs.get("shops")
        self.base_url = kwargs.get("base_url") or "https://www.atbmarket.com/catalog/388-aktsiya-7-dniv"
        self.total_pages = 5  

    def start_requests(self): 
        for shop in self.shops:
            cookies = {
                "lang": "uk",
                "natbdelivery": "0", 
                "nstore_id": shop["shop_id"],
                "ncityid": shop["city_code"]
            }
            for i in range(1, self.total_pages + 1):
                url_page = f"{self.base_url}?page={i}&sort=discount"
                yield Request(
                    url=url_page, 
                    method="GET", 
                    dont_filter=True,
                    cookies=cookies,
                    meta={"impersonate": choice(("firefox", "chrome"))},
                    callback=self.parse
                )

    def parse(self, response):
        items = response.css("article.catalog-item.js-product-container")
        for item in items:
            yield {
                "id": item.css(".catalog-item__title a::attr(href)").get(),
                "name": item.css(".catalog-item__title a::text").get(),
                "unit": item.css(".product-price__unit::text").get(),
                "price": item.css(".product-price__top::attr(value)").get(),
                "old_price": item.css(".product-price__bottom::attr(value)").get(),
                "image": item.css("img::attr(src)").get(),
                "producer": item.css(".catalog-item__counter .b-addToCart::attr(data-brand)").get(),
                "currency": item.css(".product-price__currency-abbr::text").get(),
                "categories": item.css(".catalog-item__counter .b-addToCart::attr(data-category)").get(),
                "shop_address": [shop for shop in self.shops if response.request.cookies.get("nstore_id") == shop["shop_id"]][0]["address"],
            }

🧩 Висновок

Scrapy дійсно можна інтегрувати з asyncio, і не обов’язково створювати окремі сервіси чи процеси. Достатньо змінити реактор і правильно використовувати CrawlerRunner.

Завдяки такій інтеграції можна запускати спайдери на запит користувача — швидко, без зайвого оверхеду, і з повним контролем над потоком даних.

👍ПодобаєтьсяСподобалось1
До обраногоВ обраному0
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Підписатись на коментарі