Разработка бота для мониторинга появления новых товаров у конкурентов
Новинки у конкурентов — сигнал для закупок, ценообразования и SEO. Если конкурент выложил новую линейку, а вы узнали об этом через неделю — упущены позиции в поиске и часть аудитории, которая уже выбрала другой магазин. Бот отслеживает появление новых SKU на страницах конкурентов и немедленно уведомляет команду.
Принцип работы
Мониторинг новых товаров отличается от мониторинга цен: здесь нужно следить не за конкретным URL, а за разделами каталога — категориями, страницами "Новинки", результатами поиска.
Конфигурация (URL каталога + селектор) → Scraper → Snapshot → Diff → Alert
Алгоритм:
- Скачать страницу категории/раздела конкурента
- Извлечь список товаров (URL + название + SKU)
- Сравнить с предыдущим снимком
- Новые позиции — отправить в уведомление
Схема данных
CREATE TABLE competitor_catalogs (
id BIGSERIAL PRIMARY KEY,
competitor_id INT REFERENCES competitors(id),
url TEXT NOT NULL, -- URL страницы категории
scrape_config JSONB NOT NULL, -- CSS-селекторы
check_interval INTERVAL DEFAULT '6 hours',
last_checked_at TIMESTAMP,
is_active BOOLEAN DEFAULT TRUE
);
-- Каждый товар, обнаруженный у конкурента
CREATE TABLE competitor_items (
id BIGSERIAL PRIMARY KEY,
catalog_id BIGINT REFERENCES competitor_catalogs(id),
external_url TEXT NOT NULL,
title TEXT,
external_sku VARCHAR(255),
price NUMERIC(12,2),
image_url TEXT,
first_seen_at TIMESTAMP DEFAULT NOW(),
last_seen_at TIMESTAMP DEFAULT NOW(),
is_new BOOLEAN DEFAULT TRUE, -- сброшен после уведомления
UNIQUE(catalog_id, external_url)
);
CREATE INDEX idx_competitor_items_new
ON competitor_items(catalog_id, first_seen_at)
WHERE is_new = TRUE;
Конфигурация через JSON
Каждый конкурент настраивается через JSONB-конфиг:
{
"pagination": {
"type": "url_param",
"param": "page",
"max_pages": 20
},
"item_selector": ".catalog-item",
"fields": {
"url": {"selector": "a.product-link", "attr": "href"},
"title": {"selector": ".product-name", "text": true},
"sku": {"selector": "[data-sku]", "attr": "data-sku"},
"price": {"selector": ".price", "text": true},
"image": {"selector": "img.product-image", "attr": "src"}
}
}
Scraper для каталога
class CatalogScraper
{
public function scrape(CompetitorCatalog $catalog): array
{
$config = $catalog->scrape_config;
$items = [];
$maxPages = $config['pagination']['max_pages'] ?? 1;
for ($page = 1; $page <= $maxPages; $page++) {
$url = $this->buildPageUrl($catalog->url, $config['pagination'], $page);
$html = $this->fetchWithRetry($url);
if (!$html) break;
$pageItems = $this->extractItems($html, $config);
if (empty($pageItems)) break; // Последняя страница
$items = array_merge($items, $pageItems);
// Уважительная задержка между страницами
usleep(rand(1_500_000, 3_000_000));
}
return $items;
}
private function extractItems(string $html, array $config): array
{
$crawler = new \Symfony\Component\DomCrawler\Crawler($html);
$items = [];
$crawler->filter($config['item_selector'])->each(function ($node) use ($config, &$items) {
$item = [];
foreach ($config['fields'] as $field => $fieldConfig) {
try {
$el = $node->filter($fieldConfig['selector'])->first();
if ($el->count() === 0) continue;
$item[$field] = isset($fieldConfig['attr'])
? $el->attr($fieldConfig['attr'])
: $el->text();
} catch (\Exception $e) {
continue;
}
}
if (!empty($item['url'])) {
$items[] = $item;
}
});
return $items;
}
}
Сервис обнаружения новинок
class NewProductDetectionService
{
public function process(CompetitorCatalog $catalog): DetectionResult
{
$scraped = $this->scraper->scrape($catalog);
$newItems = [];
foreach ($scraped as $item) {
$url = $this->normalizeUrl($item['url'], $catalog->url);
$existing = CompetitorItem::where([
'catalog_id' => $catalog->id,
'external_url' => $url,
])->first();
if (!$existing) {
// Новый товар!
$created = CompetitorItem::create([
'catalog_id' => $catalog->id,
'external_url' => $url,
'title' => $item['title'] ?? null,
'external_sku' => $item['sku'] ?? null,
'price' => $this->parsePrice($item['price'] ?? ''),
'image_url' => $item['image'] ?? null,
'is_new' => true,
]);
$newItems[] = $created;
} else {
// Обновить время последнего обнаружения
$existing->update(['last_seen_at' => now()]);
}
}
// Товары, исчезнувшие из каталога конкурента
$disappeared = CompetitorItem::where('catalog_id', $catalog->id)
->where('last_seen_at', '<', now()->subDays(3))
->get();
$catalog->update(['last_checked_at' => now()]);
return new DetectionResult(newItems: $newItems, disappeared: $disappeared);
}
}
Уведомления в Telegram
class NewProductNotifier
{
public function notify(DetectionResult $result, CompetitorCatalog $catalog): void
{
if ($result->newItems->isEmpty()) return;
$lines = ["🆕 *Новые товары у {$catalog->competitor->name}*\n"];
foreach ($result->newItems->take(10) as $item) {
$price = $item->price ? number_format($item->price, 0, '.', ' ') . ' руб.' : 'цена не определена';
$lines[] = "• [{$item->title}]({$item->external_url}) — {$price}";
}
if ($result->newItems->count() > 10) {
$lines[] = "\n_...и ещё " . ($result->newItems->count() - 10) . " товаров_";
}
$this->telegram->sendMessage([
'chat_id' => config('telegram.new_products_chat'),
'text' => implode("\n", $lines),
'parse_mode' => 'Markdown',
'disable_web_page_preview' => true,
]);
// Сбросить флаг is_new
CompetitorItem::whereIn('id', $result->newItems->pluck('id'))
->update(['is_new' => false]);
}
}
Обход защит от ботов
Большинство крупных магазинов используют защиту. Стратегии обхода:
| Защита | Метод обхода |
|---|---|
| Cloudflare Bot Management | Playwright + stealth plugin |
| Rate limiting | Случайные задержки 2–5 сек между запросами |
| IP-блокировки | Ротация прокси (residential proxies) |
| Требование cookies/сессии | Headless-браузер с сохранением сессии |
| JS-рендеринг | Playwright/Puppeteer вместо curl |
Расписание
// Проверка каждые 6 часов для стандартных каталогов
$schedule->command('competitors:scan-catalogs')->everySixHours();
// Ежедневный сводный отчёт
$schedule->job(new WeeklyNewProductsReportJob)->weekly()->mondays()->at('08:00');
Дополнительные возможности
- Автоматическое добавление в список закупок — найденные новинки конкурента сразу попадают в задачи байера
- Уведомление об исчезновении — товар пропал у конкурента (снят с продажи, нет в наличии)
- Ценовое сравнение — если аналог есть в нашем каталоге, сразу показать разницу цен
Сроки реализации
- CatalogScraper + конфигурация через JSON: 1–2 дня
- NewProductDetectionService + схема данных: 1 день
- Уведомления Telegram: 0.5 дня
- Playwright-адаптер для JS-сайтов: +1 день
- Ротация прокси: +0.5 дня
Итого: 3–4 рабочих дня.







