Розроблення бота для моніторингу появи нових товарів у конкурентів
Новинки у конкурентів — сигнал для закупок, ціноутворення та 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 робочих дні.







