Разработка бота для мониторинга цен конкурентов с отчётами
Мониторинг цен конкурентов — это не разовая выгрузка, а постоянный процесс сбора данных с последующей аналитикой. Бот должен отслеживать конкретные товары на конкретных сайтах, хранить историю цен и уведомлять о значимых изменениях. Без автоматизации эту работу выполняют вручную, что занимает часы ежедневно и даёт устаревшие данные.
Архитектура
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' => 'ru-RU,ru;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 рабочих дней.







