Реалізація моніторингу змін цін/наявності на зовнішніх сайтах

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

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

Інформаційні сайти або веб-програми
Сайти візитки, 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

Реалізація моніторингу змін цін/наявності на зовнішніх сайтах

Моніторинг зовнішніх сайтів потрібен у двох сценаріях: відстеження дистрибьюторів (не порушують ли рекомендовану ціну) і відстеження конкурентів по конкретних SKU для оперативного реагування. В обох випадках потрібна система, яка регулярно знімає показання з указаних URL та сигналізує при відхиленнях.

Структура системи

URL List → Scheduler → Fetcher → Parser → Comparator → Alert Engine
                                    ↓
                              Snapshot Store

Ключова особливість: система зберігає історію значень, а не лише поточне. Це дозволяє будувати графіки змін та бачити паттерни.

Модель даних

CREATE TABLE watch_targets (
    id              BIGSERIAL PRIMARY KEY,
    url             TEXT NOT NULL UNIQUE,
    label           VARCHAR(255),              -- "DNS: Samsung S24 256GB"
    our_product_id  BIGINT REFERENCES products(id),
    site_id         INT REFERENCES external_sites(id),
    check_interval  INTERVAL DEFAULT '4 hours',
    price_selector  VARCHAR(500),
    stock_selector  VARCHAR(500),
    price_type      VARCHAR(20) DEFAULT 'text', -- 'text', 'attr', 'json', 'meta'
    price_attr      VARCHAR(100),              -- для type=attr або meta
    price_regex     VARCHAR(255),              -- додаткове очищення через regex
    alert_threshold_pct NUMERIC(5,2) DEFAULT 5.0, -- сповіщати при зміні > N%
    is_active       BOOLEAN DEFAULT TRUE,
    last_checked_at TIMESTAMP,
    last_price      NUMERIC(12,2),
    last_in_stock   BOOLEAN
);

CREATE TABLE watch_snapshots (
    id              BIGSERIAL PRIMARY KEY,
    target_id       BIGINT REFERENCES watch_targets(id) ON DELETE CASCADE,
    price           NUMERIC(12,2),
    in_stock        BOOLEAN,
    raw_price_text  VARCHAR(200),
    http_status     SMALLINT,
    error           TEXT,
    captured_at     TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_snapshots_target_time ON watch_snapshots(target_id, captured_at DESC);

Гнучкий парсер ціни

Різні сайти зберігають ціну по-різному. Парсер підтримує кілька режимів:

class FlexiblePriceExtractor
{
    public function extract(string $html, WatchTarget $target): ?ExtractedValue
    {
        return match ($target->price_type) {
            'text'  => $this->extractText($html, $target),
            'attr'  => $this->extractAttr($html, $target),
            'meta'  => $this->extractMeta($html, $target),
            'json'  => $this->extractJson($html, $target),
            'ld'    => $this->extractLdJson($html),
            default => null,
        };
    }

    private function extractLdJson(string $html): ?ExtractedValue
    {
        // Schema.org Product markup — універсальний для багатьох магазинів
        $crawler = new Crawler($html);
        $nodes   = $crawler->filter('script[type="application/ld+json"]');

        foreach ($nodes as $node) {
            $data = json_decode($node->textContent, true);
            if (!$data) continue;

            $type  = $data['@type'] ?? $data[0]['@type'] ?? null;
            if (!in_array($type, ['Product', 'Offer'])) continue;

            $offer = $data['offers'] ?? $data;
            if (is_array($offer) && isset($offer[0])) $offer = $offer[0];

            $price    = $offer['price'] ?? null;
            $inStock  = ($offer['availability'] ?? '') === 'https://schema.org/InStock';

            if ($price !== null) {
                return new ExtractedValue(
                    price:   (float) $price,
                    inStock: $inStock,
                    rawText: (string) $price,
                    method:  'ld_json',
                );
            }
        }

        return null;
    }

    private function extractMeta(string $html, WatchTarget $target): ?ExtractedValue
    {
        // Open Graph / meta теги: <meta property="product:price:amount" content="29990">
        $crawler  = new Crawler($html);
        $selector = "meta[property='{$target->price_attr}'], meta[name='{$target->price_attr}']";

        try {
            $content = $crawler->filter($selector)->attr('content');
            return $this->parseNumeric($content);
        } catch (\Exception $e) {
            return null;
        }
    }

    private function extractText(string $html, WatchTarget $target): ?ExtractedValue
    {
        if (!$target->price_selector) return null;

        $crawler = new Crawler($html);
        try {
            $text = $crawler->filter($target->price_selector)->first()->text();

            if ($target->price_regex) {
                preg_match($target->price_regex, $text, $m);
                $text = $m[1] ?? $text;
            }

            return $this->parseNumeric($text);
        } catch (\Exception $e) {
            return null;
        }
    }

    private function parseNumeric(string $raw): ?ExtractedValue
    {
        $clean = preg_replace('/[^\d.,]/', '', $raw);
        $clean = str_replace(',', '.', $clean);

        // "29.990" (розділювач тисяч точкою) → "29990"
        if (preg_match('/^\d{1,3}\.\d{3}$/', $clean)) {
            $clean = str_replace('.', '', $clean);
        }

        if (!is_numeric($clean) || (float) $clean <= 0) return null;

        return new ExtractedValue(price: (float) $clean, rawText: $raw);
    }
}

Job перевірки цілі

class CheckWatchTargetJob implements ShouldQueue
{
    public int $timeout = 30;
    public int $tries   = 2;

    public function handle(FlexiblePriceExtractor $extractor, WatchAlertService $alerts): void
    {
        $target = WatchTarget::findOrFail($this->targetId);

        // Fetch
        $response = $this->fetch($target->url);
        if (!$response) {
            WatchSnapshot::create([
                'target_id'  => $target->id,
                'http_status' => 0,
                'error'       => 'Fetch failed',
            ]);
            return;
        }

        // Parse
        $extracted = $extractor->extract($response->body(), $target);
        $httpStatus = $response->status();

        WatchSnapshot::create([
            'target_id'      => $target->id,
            'price'          => $extracted?->price,
            'in_stock'       => $extracted?->inStock,
            'raw_price_text' => $extracted?->rawText,
            'http_status'    => $httpStatus,
        ]);

        // Compare and alert
        if ($extracted && $target->last_price) {
            $changePct = abs($extracted->price - $target->last_price) / $target->last_price * 100;

            if ($changePct >= $target->alert_threshold_pct) {
                $alerts->priceChanged($target, $target->last_price, $extracted->price);
            }
        }

        if ($extracted && $target->last_in_stock !== null && $extracted->inStock !== $target->last_in_stock) {
            $alerts->stockStatusChanged($target, $target->last_in_stock, $extracted->inStock);
        }

        $target->update([
            'last_checked_at' => now(),
            'last_price'      => $extracted?->price ?? $target->last_price,
            'last_in_stock'   => $extracted?->inStock ?? $target->last_in_stock,
        ]);
    }
}

Диспетчер розписання перевірок

class WatchScheduler
{
    public function dispatch(): void
    {
        WatchTarget::active()
            ->where(function ($q) {
                $q->whereNull('last_checked_at')
                  ->orWhereRaw("last_checked_at + check_interval < NOW()");
            })
            ->orderBy('last_checked_at')
            ->chunk(200, function ($targets) {
                foreach ($targets as $target) {
                    CheckWatchTargetJob::dispatch($target->id)
                        ->onQueue('monitoring');
                }
            });
    }
}

Інтерфейс управління моніторингом

В адміністративній панелі:

  • Список URL з поточною ціною та часом останньої перевірки
  • Кнопка «Перевірити зараз»
  • Графік змін ціни за 30 днів
  • Настройка порога сповіщення для кожного URL
  • Масове додавання URL з CSV

Паттерни сповіщень

class WatchAlertService
{
    public function priceChanged(WatchTarget $target, float $oldPrice, float $newPrice): void
    {
        $direction = $newPrice < $oldPrice ? '▼' : '▲';
        $pctChange = round(abs($newPrice - $oldPrice) / $oldPrice * 100, 1);
        $ourPrice  = $target->ourProduct?->price;

        $text = "{$direction} *Зміна ціни* на {$target->site->name}\n"
            . "{$target->label}\n"
            . "Було: " . number_format($oldPrice, 0, '.', ' ') . " грн.\n"
            . "Стало: " . number_format($newPrice, 0, '.', ' ') . " грн. ({$pctChange}%)\n";

        if ($ourPrice) {
            $diff = round(($newPrice - $ourPrice) / $ourPrice * 100, 1);
            $text .= "Наша ціна: " . number_format($ourPrice, 0, '.', ' ') . " грн. "
                . ($diff > 0 ? "(ми дешевше на {$diff}%)" : "(вони дешевше на " . abs($diff) . "%)") . "\n";
        }

        $text .= "\n[Відкрити сторінку]({$target->url})";

        $this->telegram->sendMessage([
            'chat_id'    => config('telegram.price_watch_chat'),
            'text'       => $text,
            'parse_mode' => 'Markdown',
        ]);
    }
}

Графік реалізації

  • Схема даних + FlexiblePriceExtractor + LD-JSON: 1–2 дні
  • CheckWatchTargetJob + диспетчер: 0.5 дня
  • Telegram-сповіщення: 0.5 дня
  • Інтерфейс управління + графіки: 1 день
  • Playwright-адаптер для JS-сайтів: +1 день

Разом: 3–4 робочі дні.