Розроблення бота для моніторингу відзивів про товари на зовнішніх майданчиках
Відзиви на Yandex.Market, Ozon, Wildberries, Otzovik, Google Maps та інших майданчиках впливають на рішення покупця задовго до того, як користувач зайде на ваш сайт. Своєчасна реакція на негативний відзив зменшує репутаційний збиток; опрацьований негатив часто перетворюється на лояльного клієнта.
Завдання бота
- Сканувати сторінки товарів/компанії на зовнішніх майданчиках
- Виявляти нові відзиви (позитивні і негативні)
- Сповіщати команду про нові відзиви негайно
- Зберігати історію відзивів для аналітики
- Обчислювати тренди рейтингу по майданчиках
Схема даних
CREATE TABLE review_sources (
id BIGSERIAL PRIMARY KEY,
platform VARCHAR(50) NOT NULL, -- 'yandex_market', 'ozon', 'google', 'otzovik'
product_id BIGINT REFERENCES products(id),
external_url TEXT NOT NULL,
external_id VARCHAR(255), -- ID картки на майданчику
scrape_config JSONB,
is_active BOOLEAN DEFAULT TRUE,
UNIQUE(platform, external_url)
);
CREATE TABLE reviews (
id BIGSERIAL PRIMARY KEY,
source_id BIGINT REFERENCES review_sources(id),
external_id VARCHAR(255), -- ID відзиву на майданчику
author VARCHAR(255),
rating SMALLINT, -- 1–5
text TEXT,
pros TEXT,
cons TEXT,
published_at TIMESTAMP,
discovered_at TIMESTAMP DEFAULT NOW(),
sentiment VARCHAR(20), -- 'positive', 'negative', 'neutral' (ML)
is_notified BOOLEAN DEFAULT FALSE,
UNIQUE(source_id, external_id)
);
CREATE INDEX idx_reviews_rating ON reviews(source_id, rating);
CREATE INDEX idx_reviews_notified ON reviews(source_id) WHERE is_notified = FALSE;
Адаптери для платформ
Кожна майданчик — окремий адаптер з реалізацією парсингу.
Yandex.Market (API):
class YandexMarketReviewAdapter implements ReviewAdapterInterface
{
// Yandex.Market надає API для партнерів для отримання відзивів
public function fetchReviews(ReviewSource $source): array
{
$modelId = $source->external_id;
$response = Http::withHeaders([
'Authorization' => 'Bearer ' . config('services.yandex_market.token'),
])->get("https://api.partner.market.yandex.ru/v2/models/{$modelId}/reviews", [
'count' => 30,
'page' => 1,
]);
return collect($response->json('result.reviews', []))
->map(fn($r) => new ReviewDTO(
externalId: (string) $r['id'],
author: $r['author']['name'] ?? 'Анонім',
rating: (int) $r['grade'],
text: $r['text'] ?? '',
pros: $r['pros'] ?? null,
cons: $r['cons'] ?? null,
publishedAt: Carbon::parse($r['date']),
))
->toArray();
}
}
Парсинг Ozon (HTML):
class OzonReviewAdapter implements ReviewAdapterInterface
{
public function fetchReviews(ReviewSource $source): array
{
// Ozon завантажує відзиви через XHR, тому потрібен браузер
$data = $this->playwright->evaluate($source->external_url, <<<JS
await page.waitForSelector('[data-widget="webReviewProductScore"]', {timeout: 10000});
const items = document.querySelectorAll('[data-widget="webSingleReview"]');
return Array.from(items).map(el => ({
id: el.dataset.reviewId,
rating: parseInt(el.querySelector('[data-rating]')?.dataset.rating) || 0,
text: el.querySelector('.review-text')?.textContent?.trim() || '',
pros: el.querySelector('.pros')?.textContent?.trim() || null,
cons: el.querySelector('.cons')?.textContent?.trim() || null,
author: el.querySelector('.author-name')?.textContent?.trim() || 'Анонім',
date: el.querySelector('time')?.getAttribute('datetime'),
}));
JS);
return collect($data)->map(fn($r) => new ReviewDTO(
externalId: $r['id'],
author: $r['author'],
rating: $r['rating'],
text: $r['text'],
pros: $r['pros'],
cons: $r['cons'],
publishedAt: $r['date'] ? Carbon::parse($r['date']) : now(),
))->toArray();
}
}
Визначення тональності відзиву
class SentimentAnalyzer
{
private array $negativeKeywords = [
'брак', 'сломаний', 'не працює', 'повернення', 'обман',
'розчарований', 'жах', 'кошмар', 'сміття', 'дрань',
];
private array $positiveKeywords = [
'чудово', 'супер', 'доволен', 'рекомендую', 'перевищив',
'швидко', 'якісно', 'дякую',
];
public function analyze(ReviewDTO $review): string
{
if ($review->rating <= 2) return 'negative';
if ($review->rating >= 4) return 'positive';
// Для рейтингу 3 — аналіз тексту
$text = mb_strtolower($review->text . ' ' . $review->cons);
foreach ($this->negativeKeywords as $kw) {
if (str_contains($text, $kw)) return 'negative';
}
return 'neutral';
}
}
Для точного аналізу тональності — інтеграція з OpenAI:
public function analyzeWithAI(string $text): string
{
$response = $this->openai->chat()->create([
'model' => 'gpt-4o-mini',
'messages' => [
['role' => 'system', 'content' => 'Визнач тональність відзиву. Відповідь одним словом: positive, negative або neutral.'],
['role' => 'user', 'content' => $text],
],
'max_tokens' => 10,
]);
return in_array($response->choices[0]->message->content, ['positive', 'negative', 'neutral'])
? $response->choices[0]->message->content
: 'neutral';
}
Сповіщення
class ReviewNotifier
{
public function notifyNew(Review $review): void
{
$emoji = match ($review->sentiment) {
'positive' => '⭐',
'negative' => '🚨',
default => '💬',
};
$stars = str_repeat('★', $review->rating) . str_repeat('☆', 5 - $review->rating);
$text = "{$emoji} *Новий відзив* — {$review->source->platform}\n"
. "{$stars} {$review->rating}/5\n"
. "*{$review->author}*\n\n"
. mb_substr($review->text, 0, 300)
. (mb_strlen($review->text) > 300 ? '...' : '') . "\n\n"
. "[Відкрити відзив]({$review->source->external_url})";
$chatId = $review->sentiment === 'negative'
? config('telegram.urgent_reviews_chat')
: config('telegram.reviews_chat');
$this->telegram->sendMessage([
'chat_id' => $chatId,
'text' => $text,
'parse_mode' => 'Markdown',
]);
$review->update(['is_notified' => true]);
}
}
Аналітика рейтингу
// Агрегат рейтингу по майданчиках за останні 30 днів
SELECT
rs.platform,
COUNT(*) AS total_reviews,
ROUND(AVG(r.rating), 2) AS avg_rating,
COUNT(*) FILTER (WHERE r.rating <= 2) AS negative_count,
COUNT(*) FILTER (WHERE r.rating >= 4) AS positive_count
FROM reviews r
JOIN review_sources rs ON r.source_id = rs.id
WHERE r.published_at >= NOW() - INTERVAL '30 days'
GROUP BY rs.platform
ORDER BY avg_rating;
Розписання перевірки
// Швидкі майданчики з API — кожну годину
$schedule->command('reviews:check --platform=yandex_market')->hourly();
// Парсинг через браузер — кожні 4 години (ресурсомістко)
$schedule->command('reviews:check --platform=ozon')->everyFourHours();
$schedule->command('reviews:check --platform=wildberries')->everyFourHours();
// Щотижневий звіт зі трендом рейтингу
$schedule->job(new WeeklyReviewsReportJob)->weekly()->mondays()->at('09:00');
Графік реалізації
- Схема даних + базовий адаптер (HTML-парсинг): 1–2 дні
- Адаптер для API Yandex.Market: 0.5 дня
- Playwright-адаптери для Ozon/WB: 1–2 дні
- SentimentAnalyzer + сповіщення Telegram: 1 день
- Дашборд аналітики рейтингу в админці: 1 день
Разом: 4–5 робочих днів.







