Реализация мониторинга изменений цен/наличия на внешних сайтах
Мониторинг внешних сайтов — задача, с которой сталкиваются владельцы интернет-магазинов и менеджеры по закупкам. Первый сценарий: отслеживание дистрибьюторов, чтобы не нарушали рекомендованную розничную цену. Второй — наблюдение за конкурентами по конкретным 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 месяца после запуска.
Оцените экономию на вашем объёме — свяжитесь с нами для рассчёта. Получите консультацию по вашему проекту — рассчитаем стоимость и сроки за один день.







