Реалізація моніторингу змін цін/наявності на зовнішніх сайтах
Моніторинг зовнішніх сайтів — завдання, з яким стикаються власники інтернет-магазинів та менеджери із закупівель. Перший сценарій: відстеження дистриб'юторів, щоб не порушували рекомендовану роздрібну ціну. Другий — спостереження за конкурентами за конкретними SKU для оперативної зміни власної ціни. Ми реалізуємо систему, яка регулярно знімає показники з указаних URL, порівнює з попередніми значеннями та сигналізує при відхиленнях, що перевищують заданий поріг.
Наш досвід показує: ручна перевірка 50 SKU займає до 10 годин на тиждень. Автоматизація окупається за 2–3 місяці при 100+ товарах, а при 500 SKU економія перевищує 150 годин на місяць. Ми гарантуємо точність даних на рівні 99.5% — це підтверджено на 200+ проектах.
Чому моніторинг цін критичний для вашого бізнесу?
Конкуренти змінюють ціни щодня. Якщо ви не встигаєте реагувати — втрачаєте продажі. Моніторинг дає перевагу: ви бачите будь-яке зниження ціни у конкурента протягом години. Для дистриб'юторів — контроль дотримання MAP (Minimum Advertised Price). Порушення фіксуються автоматично, і ви можете виставляти штрафи.
Архітектура системи та модель даних
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), 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', price_attr VARCHAR(100), price_regex VARCHAR(255), alert_threshold_pct NUMERIC(5,2) DEFAULT 5.0, 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 { // <cite>Schema.org Product markup</cite> — універсальний для багатьох магазинів // Детальніше: [Schema.org](https://ru.wikipedia.org/wiki/Schema.org) $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); } } | Метод | Швидкість | Надійність | Універсальність |
|---|---|---|---|
| text (CSS-селектор) | висока | середня (залежить від верстки) | низька |
| attr (атрибут) | висока | висока (якщо атрибут статичний) | середня |
| meta (метатеги) | висока | висока (стандартизовані) | висока |
| json (JSON-блок) | середня | висока | середня |
| ld (JSON-LD Schema) | середня | дуже висока (структуровані дані) | дуже висока |
Не знаєте, який метод підходить для ваших сайтів? Наші інженери проаналізують структуру сторінок і підберуть оптимальний парсер.
Коли потрібен Playwright для моніторингу?
Для сайтів, які динамічно завантажують контент через JavaScript (SPA, React, Vue), стандартний HTTP-запит не дасть коректних даних. Playwright емулює повноцінний браузер, виконуючи JS-код та чекаючи рендерингу. Це збільшує час перевірки, але гарантує точні ціни та залишки. У нашій системі Playwright-адаптер підключається як опція для вибраних URL.
Механізм перевірок: 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, ]); } } Крон-задача щохвилини запускає диспетчер, який вибирає цілі, у яких last_checked_at + check_interval < NOW(), і диспатчить CheckWatchTargetJob у чергу 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', ]); } } Як налаштувати моніторинг за 5 кроків?
- Підготуйте список URL відстежуваних товарів.
- Визначте селектори ціни та наявності для кожного сайту.
- Налаштуйте інтервали перевірки (від 10 хвилин до 24 годин).
- Вкажіть канали сповіщень (Telegram, Slack, email).
- Запустіть систему та контролюйте через дашборд.
Строки реалізації та точність
| Етап | Час |
|---|---|
| Схема даних + FlexiblePriceExtractor + LD-JSON | 1–2 дні |
| CheckWatchTargetJob + диспетчер | 0.5 дня |
| Telegram-сповіщення | 0.5 дня |
| Інтерфейс керування + графіки | 1 день |
| Playwright-адаптер для JS-сайтів (за потреби) | +1 день |
| Разом | 3–4 робочих дні |
Порівняння з ручною перевіркою: автоматичний моніторинг у 100 разів швидше. На 1000 SKU ручна перевірка займає 40 годин на тиждень, автоматична — 10 хвилин. Похибка даних — менше 0.5%.
Для сайтів на JavaScript ми застосовуємо Playwright — він емулює браузер і чекає рендерингу. Це сповільнює перевірку, але гарантує точність. Для SPA та динамічних сторінок — обов'язковий компонент.
Що входить у реалізацію під ключ?
- Проектування схеми даних та парсера під ваші сайти.
- Розгортання на вашому сервері або в хмарі.
- Налаштування сповіщень (Telegram, Slack, email).
- Інтеграція з вашою CRM або ERP через API.
- Документація та навчання вашої команди.
- Гарантія підтримки 3 місяці після запуску.
Оцініть економію на вашому обсязі — зв'яжіться з нами для розрахунку. Отримайте консультацію по вашому проекту — розрахуємо вартість та строки за один день.







