Розроблення бота для моніторингу цін конкурентів з звітами
Моніторинг цін конкурентів — це не разова виписка, а постійний процес збору даних з подальшою аналітикою. Бот повинен відстежувати конкретні товари на конкретних сайтах, зберігати історію цін і повідомляти про значимі зміни. Без автоматизації цю роботу виконують вручну, що займає години щодня і дає застарілі дані.
Архітектура
Scheduler (cron) → Scraper Workers → Price DB → Analytics → Reports/Alerts
Ключові компоненти:
- Scraper — отримує HTML або JSON з сайту конкурента
- Parser — вилучає ціну з отриманого контенту
- Storage — зберігає історію змін
- Analyzer — обчислює дельти, тренди
- Notifier — відправляє звіти в Telegram/Email
Схема даних
CREATE TABLE monitored_products (
id BIGSERIAL PRIMARY KEY,
our_product_id BIGINT REFERENCES products(id),
competitor_id INT REFERENCES competitors(id),
url TEXT NOT NULL,
selector VARCHAR(500), -- CSS-селектор для ціни
last_price NUMERIC(12,2),
last_checked_at TIMESTAMP,
is_active BOOLEAN DEFAULT TRUE,
UNIQUE(competitor_id, url)
);
CREATE TABLE price_snapshots (
id BIGSERIAL PRIMARY KEY,
monitored_id BIGINT REFERENCES monitored_products(id),
price NUMERIC(12,2),
in_stock BOOLEAN,
raw_text VARCHAR(100), -- "29 990 руб." до парсингу
captured_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_snapshots_monitored_captured
ON price_snapshots(monitored_id, captured_at DESC);
Scraper з ротацією User-Agent і прокси
class CompetitorScraper
{
private array $userAgents = [
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36...',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15...',
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36...',
];
public function fetch(string $url): ?string
{
$response = Http::withHeaders([
'User-Agent' => $this->userAgents[array_rand($this->userAgents)],
'Accept-Language' => 'uk-UA,uk;q=0.9',
'Accept-Encoding' => 'gzip, deflate, br',
])
->timeout(15)
->retry(3, 2000, fn($e) => $e instanceof ConnectionException)
->get($url);
if ($response->status() === 429) {
// Rate limit — затримка перед retry
sleep(rand(30, 60));
return null;
}
if (!$response->successful()) {
Log::warning("Scraper failed: {$url}", ['status' => $response->status()]);
return null;
}
return $response->body();
}
}
Для сайтів з JavaScript-рендерингом використовуйте Playwright API або Browserless.io:
class PlaywrightScraper
{
public function fetch(string $url): ?string
{
$response = Http::post(config('scraper.browserless_url') . '/content', [
'url' => $url,
'waitFor' => '.price, [data-price], .product-price', // чекаємо появу елемента
'options' => ['timeout' => 20000],
]);
return $response->successful() ? $response->body() : null;
}
}
Парсер цін
class PriceParser
{
public function parse(string $html, MonitoredProduct $config): ?ParsedPrice
{
$crawler = new Symfony\Component\DomCrawler\Crawler($html);
// Спробувати CSS-селектор з конфігурації
if ($config->selector) {
try {
$text = $crawler->filter($config->selector)->first()->text();
return $this->extractPrice($text);
} catch (\Exception $e) {
// Селектор не спрацював — fallback на евристику
}
}
// Евристичний пошук за типовими паттернами
$priceSelectors = [
'[itemprop="price"]',
'.price__current',
'.product-price',
'[data-price]',
'.js-price',
];
foreach ($priceSelectors as $selector) {
try {
$node = $crawler->filter($selector)->first();
if ($node->count()) {
// Спробувати атрибут data-price спочатку
$dataPrice = $node->attr('data-price') ?? $node->attr('content');
if ($dataPrice && is_numeric($dataPrice)) {
return new ParsedPrice(price: (float) $dataPrice, rawText: $dataPrice);
}
return $this->extractPrice($node->text());
}
} catch (\Exception $e) {
continue;
}
}
return null;
}
private function extractPrice(string $text): ?ParsedPrice
{
// Прибрати пробіли, замінити кому на точку
$normalized = preg_replace('/[^\d,.]/', '', $text);
$normalized = str_replace(',', '.', $normalized);
// Прибрати розділювачі тисяч: "29.990" → "29990" (якщо немає копійок)
if (preg_match('/^\d{1,3}[.]\d{3}$/', $normalized)) {
$normalized = str_replace('.', '', $normalized);
}
if (!is_numeric($normalized) || (float)$normalized <= 0) {
return null;
}
return new ParsedPrice(price: (float)$normalized, rawText: $text);
}
}
Job для перевірки ціни
class CheckCompetitorPriceJob implements ShouldQueue
{
public int $timeout = 60;
public function handle(
CompetitorScraper $scraper,
PriceParser $parser,
PriceAlertService $alerts,
): void {
$monitored = MonitoredProduct::findOrFail($this->monitoredId);
$html = $scraper->fetch($monitored->url);
if (!$html) return;
$parsed = $parser->parse($html, $monitored);
if (!$parsed) {
Log::warning("Price parse failed", ['url' => $monitored->url]);
return;
}
PriceSnapshot::create([
'monitored_id' => $monitored->id,
'price' => $parsed->price,
'in_stock' => $parsed->inStock,
'raw_text' => $parsed->rawText,
]);
// Повідомлення при значимій зміні
if ($monitored->last_price) {
$changePct = abs($parsed->price - $monitored->last_price) / $monitored->last_price * 100;
if ($changePct >= 5) {
$alerts->priceChanged($monitored, $monitored->last_price, $parsed->price);
}
}
$monitored->update([
'last_price' => $parsed->price,
'last_checked_at' => now(),
]);
}
}
Звіти
Щоденний звіт формується за розписанням та відправляється в Telegram-групу:
class DailyPriceReportJob implements ShouldQueue
{
public function handle(): void
{
$report = $this->buildReport();
$this->telegram->sendMessage([
'chat_id' => config('telegram.price_alerts_chat'),
'text' => $this->formatMarkdown($report),
'parse_mode' => 'Markdown',
]);
}
private function buildReport(): array
{
return MonitoredProduct::with(['ourProduct', 'competitor'])
->whereHas('snapshots', fn($q) => $q->where('captured_at', '>=', now()->subDay()))
->get()
->map(function ($m) {
$yesterday = $m->snapshots()->where('captured_at', '>=', now()->subDays(2))
->where('captured_at', '<', now()->subDay())->avg('price');
$today = $m->last_price;
return [
'product' => $m->ourProduct->name,
'competitor' => $m->competitor->name,
'price_now' => $today,
'price_was' => $yesterday,
'delta_pct' => $yesterday ? round(($today - $yesterday) / $yesterday * 100, 1) : null,
'our_price' => $m->ourProduct->price,
'position' => $today < $m->ourProduct->price ? 'cheaper' : 'expensive',
];
})
->toArray();
}
}
Розписання перевірок
// Високопріоритетні конкуренти — кожні 2 години
$schedule->command('monitor:check --priority=high')->everyTwoHours();
// Решта — раз у 6 годин
$schedule->command('monitor:check --priority=normal')->everySixHours();
// Звіт — в 9:00 щодня
$schedule->job(new DailyPriceReportJob)->dailyAt('09:00');
Строки реалізації
- Scraper + PriceParser + схема даних: 1–2 дні
- Playwright-адаптер для JS-сайтів: +1 день
- Job-система + розписання: 0.5 дня
- Звіти в Telegram + Email: 1 день
- Інтерфейс управління списком конкурентів в админці: 1 день
Разом: 4–5 робочих днів.







