Розробка бота для моніторингу цін конкурентів зі звітами

Наша компанія займається розробкою, підтримкою та обслуговуванням сайтів будь-якої складності. Від простих односторінкових сайтів до масштабних кластерних систем, побудованих на мікро сервісах. Досвід розробників підтверджено сертифікатами від вендорів.

Розробка та обслуговування будь-яких видів сайтів:

Інформаційні сайти або веб-програми
Сайти візитки, landing page, корпоративні сайти, онлайн каталоги, квіз, промо-сайти, блоги, ресурси новин, інформаційні портали, форуми, агрегатори
Сайти або веб-програми електронної комерції
Інтернет-магазини, B2B-портали, маркетплейси, онлайн-обмінники, кешбек-сайти, біржі, дропшиппінг-платформи, парсери товарів
Веб-програми для управління бізнес-процесами
CRM-системи, ERP-системи, корпоративні портали, системи управління виробництвом, парсери інформації
Сайти або веб-програми електронних послуг
Дошки оголошень, онлайн-школи, онлайн-кінотеатри, конструктори сайтів, портали надання електронних послуг, відеохостинги, тематичні портали

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

Пропоновані послуги
Показано 1 з 1 послугУсі 2065 послуг
Розробка бота для моніторингу цін конкурентів зі звітами
Середня
~3-5 робочих днів
Часті питання

Наші компетенції:

Етапи розробки

Останні роботи

  • image_website-b2b-advance_0.png
    Розробка сайту компанії B2B ADVANCE
    1262
  • image_web-applications_feedme_466_0.webp
    Розробка веб-додатків для компанії FEEDME
    1171
  • image_websites_belfingroup_462_0.webp
    Розробка веб-сайту для компанії БЕЛФІНГРУП
    874
  • image_ecommerce_furnoro_435_0.webp
    Розробка інтернет магазину для компанії FURNORO
    1094
  • image_crm_enviok_479_0.webp
    Розробка веб-додатків для компанії Enviok
    831
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Розробка веб-сайту для компанії ФІКСПЕР
    851

Розроблення бота для моніторингу цін конкурентів з звітами

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

Архітектура

Scheduler (cron) → Scraper Workers → Price DB → Analytics → Reports/Alerts

Ключові компоненти:

  • Scraper — отримує HTML або JSON з сайту конкурента
  • Parser — вилучає ціну з отриманого контенту
  • Storage — зберігає історію змін
  • Analyzer — обчислює дельти, тренди
  • Notifier — відправляє звіти в Telegram/Email

Схема даних

CREATE TABLE monitored_products (
    id              BIGSERIAL PRIMARY KEY,
    our_product_id  BIGINT REFERENCES products(id),
    competitor_id   INT REFERENCES competitors(id),
    url             TEXT NOT NULL,
    selector        VARCHAR(500),          -- CSS-селектор для ціни
    last_price      NUMERIC(12,2),
    last_checked_at TIMESTAMP,
    is_active       BOOLEAN DEFAULT TRUE,
    UNIQUE(competitor_id, url)
);

CREATE TABLE price_snapshots (
    id              BIGSERIAL PRIMARY KEY,
    monitored_id    BIGINT REFERENCES monitored_products(id),
    price           NUMERIC(12,2),
    in_stock        BOOLEAN,
    raw_text        VARCHAR(100),          -- "29 990 руб." до парсингу
    captured_at     TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_snapshots_monitored_captured
    ON price_snapshots(monitored_id, captured_at DESC);

Scraper з ротацією User-Agent і прокси

class CompetitorScraper
{
    private array $userAgents = [
        'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36...',
        'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15...',
        'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36...',
    ];

    public function fetch(string $url): ?string
    {
        $response = Http::withHeaders([
            'User-Agent'      => $this->userAgents[array_rand($this->userAgents)],
            'Accept-Language' => 'uk-UA,uk;q=0.9',
            'Accept-Encoding' => 'gzip, deflate, br',
        ])
        ->timeout(15)
        ->retry(3, 2000, fn($e) => $e instanceof ConnectionException)
        ->get($url);

        if ($response->status() === 429) {
            // Rate limit — затримка перед retry
            sleep(rand(30, 60));
            return null;
        }

        if (!$response->successful()) {
            Log::warning("Scraper failed: {$url}", ['status' => $response->status()]);
            return null;
        }

        return $response->body();
    }
}

Для сайтів з JavaScript-рендерингом використовуйте Playwright API або Browserless.io:

class PlaywrightScraper
{
    public function fetch(string $url): ?string
    {
        $response = Http::post(config('scraper.browserless_url') . '/content', [
            'url'     => $url,
            'waitFor' => '.price, [data-price], .product-price',  // чекаємо появу елемента
            'options' => ['timeout' => 20000],
        ]);

        return $response->successful() ? $response->body() : null;
    }
}

Парсер цін

class PriceParser
{
    public function parse(string $html, MonitoredProduct $config): ?ParsedPrice
    {
        $crawler = new Symfony\Component\DomCrawler\Crawler($html);

        // Спробувати CSS-селектор з конфігурації
        if ($config->selector) {
            try {
                $text = $crawler->filter($config->selector)->first()->text();
                return $this->extractPrice($text);
            } catch (\Exception $e) {
                // Селектор не спрацював — fallback на евристику
            }
        }

        // Евристичний пошук за типовими паттернами
        $priceSelectors = [
            '[itemprop="price"]',
            '.price__current',
            '.product-price',
            '[data-price]',
            '.js-price',
        ];

        foreach ($priceSelectors as $selector) {
            try {
                $node = $crawler->filter($selector)->first();
                if ($node->count()) {
                    // Спробувати атрибут data-price спочатку
                    $dataPrice = $node->attr('data-price') ?? $node->attr('content');
                    if ($dataPrice && is_numeric($dataPrice)) {
                        return new ParsedPrice(price: (float) $dataPrice, rawText: $dataPrice);
                    }
                    return $this->extractPrice($node->text());
                }
            } catch (\Exception $e) {
                continue;
            }
        }

        return null;
    }

    private function extractPrice(string $text): ?ParsedPrice
    {
        // Прибрати пробіли, замінити кому на точку
        $normalized = preg_replace('/[^\d,.]/', '', $text);
        $normalized = str_replace(',', '.', $normalized);

        // Прибрати розділювачі тисяч: "29.990" → "29990" (якщо немає копійок)
        if (preg_match('/^\d{1,3}[.]\d{3}$/', $normalized)) {
            $normalized = str_replace('.', '', $normalized);
        }

        if (!is_numeric($normalized) || (float)$normalized <= 0) {
            return null;
        }

        return new ParsedPrice(price: (float)$normalized, rawText: $text);
    }
}

Job для перевірки ціни

class CheckCompetitorPriceJob implements ShouldQueue
{
    public int $timeout = 60;

    public function handle(
        CompetitorScraper $scraper,
        PriceParser       $parser,
        PriceAlertService $alerts,
    ): void {
        $monitored = MonitoredProduct::findOrFail($this->monitoredId);

        $html   = $scraper->fetch($monitored->url);
        if (!$html) return;

        $parsed = $parser->parse($html, $monitored);
        if (!$parsed) {
            Log::warning("Price parse failed", ['url' => $monitored->url]);
            return;
        }

        PriceSnapshot::create([
            'monitored_id' => $monitored->id,
            'price'        => $parsed->price,
            'in_stock'     => $parsed->inStock,
            'raw_text'     => $parsed->rawText,
        ]);

        // Повідомлення при значимій зміні
        if ($monitored->last_price) {
            $changePct = abs($parsed->price - $monitored->last_price) / $monitored->last_price * 100;
            if ($changePct >= 5) {
                $alerts->priceChanged($monitored, $monitored->last_price, $parsed->price);
            }
        }

        $monitored->update([
            'last_price'      => $parsed->price,
            'last_checked_at' => now(),
        ]);
    }
}

Звіти

Щоденний звіт формується за розписанням та відправляється в Telegram-групу:

class DailyPriceReportJob implements ShouldQueue
{
    public function handle(): void
    {
        $report = $this->buildReport();
        $this->telegram->sendMessage([
            'chat_id'    => config('telegram.price_alerts_chat'),
            'text'       => $this->formatMarkdown($report),
            'parse_mode' => 'Markdown',
        ]);
    }

    private function buildReport(): array
    {
        return MonitoredProduct::with(['ourProduct', 'competitor'])
            ->whereHas('snapshots', fn($q) => $q->where('captured_at', '>=', now()->subDay()))
            ->get()
            ->map(function ($m) {
                $yesterday = $m->snapshots()->where('captured_at', '>=', now()->subDays(2))
                    ->where('captured_at', '<', now()->subDay())->avg('price');
                $today = $m->last_price;

                return [
                    'product'    => $m->ourProduct->name,
                    'competitor' => $m->competitor->name,
                    'price_now'  => $today,
                    'price_was'  => $yesterday,
                    'delta_pct'  => $yesterday ? round(($today - $yesterday) / $yesterday * 100, 1) : null,
                    'our_price'  => $m->ourProduct->price,
                    'position'   => $today < $m->ourProduct->price ? 'cheaper' : 'expensive',
                ];
            })
            ->toArray();
    }
}

Розписання перевірок

// Високопріоритетні конкуренти — кожні 2 години
$schedule->command('monitor:check --priority=high')->everyTwoHours();

// Решта — раз у 6 годин
$schedule->command('monitor:check --priority=normal')->everySixHours();

// Звіт — в 9:00 щодня
$schedule->job(new DailyPriceReportJob)->dailyAt('09:00');

Строки реалізації

  • Scraper + PriceParser + схема даних: 1–2 дні
  • Playwright-адаптер для JS-сайтів: +1 день
  • Job-система + розписання: 0.5 дня
  • Звіти в Telegram + Email: 1 день
  • Інтерфейс управління списком конкурентів в админці: 1 день

Разом: 4–5 робочих днів.