Разработка бота-парсера наличия товаров у конкурентов
Данные об остатках конкурентов открывают несколько бизнес-возможностей: повысить цену, когда у конкурента кончился товар; сигнализировать менеджеру о пополнении запасов; показывать на сайте "Осталось мало" когда у конкурентов пусто. Бот мониторит наличие по расписанию и реагирует на изменения.
Что отслеживается
- Наличие/отсутствие — есть ли товар в продаже
- Количество — если сайт показывает остаток ("Осталось 3 шт")
- Статус — в наличии / под заказ / снят с продажи / временно нет
- Даты появления — когда товар вернулся в продажу
Парсер статусов наличия
// app/Services/StockMonitor/StockStatusExtractor.php
class StockStatusExtractor
{
// Словари для определения статуса по тексту
private array $inStockPatterns = [
'/в\s*наличии/ui',
'/есть\s*в\s*наличии/ui',
'/доступен/ui',
'/купить/ui',
'/в корзину/ui',
'/in\s*stock/i',
'/available/i',
];
private array $outOfStockPatterns = [
'/нет\s*в\s*наличии/ui',
'/отсутствует/ui',
'/нет\s*на\s*складе/ui',
'/временно\s*не\s*доступен/ui',
'/распродан/ui',
'/out\s*of\s*stock/i',
'/unavailable/i',
'/sold\s*out/i',
];
private array $preorderPatterns = [
'/под\s*заказ/ui',
'/предзаказ/ui',
'/preorder/i',
'/pre-order/i',
];
public function extract(string $html, array $config = []): StockStatus
{
$crawler = new Crawler($html);
// Стратегия 1: CSS-атрибуты (самый надёжный)
if ($availability = $this->extractFromMicrodata($crawler)) {
return $availability;
}
// Стратегия 2: JSON-LD
if ($availability = $this->extractFromJsonLd($crawler)) {
return $availability;
}
// Стратегия 3: Кастомные CSS-селекторы из конфига
if (!empty($config['stock_selector'])) {
if ($availability = $this->extractWithSelector($crawler, $config)) {
return $availability;
}
}
// Стратегия 4: Кнопка "Купить" / "В корзину"
return $this->extractFromButtonState($crawler);
}
private function extractFromMicrodata(Crawler $crawler): ?StockStatus
{
$node = $crawler->filter('[itemprop="availability"]')->first();
if (!$node->count()) return null;
$value = strtolower(
$node->attr('content') ?? $node->text()
);
return match (true) {
str_contains($value, 'instock') => StockStatus::inStock(),
str_contains($value, 'outofstock') => StockStatus::outOfStock(),
str_contains($value, 'preorder') => StockStatus::preOrder(),
default => null,
};
}
private function extractFromJsonLd(Crawler $crawler): ?StockStatus
{
foreach ($crawler->filter('script[type="application/ld+json"]') as $script) {
$json = json_decode($script->textContent, true);
if (!$json) continue;
$items = $json['@graph'] ?? [$json];
foreach ($items as $item) {
if (($item['@type'] ?? '') !== 'Product') continue;
$offers = $item['offers'] ?? [];
$offer = isset($offers['@type']) ? $offers : ($offers[0] ?? null);
if (!$offer) continue;
$availability = strtolower($offer['availability'] ?? '');
return match (true) {
str_contains($availability, 'instock') => StockStatus::inStock(),
str_contains($availability, 'outofstock') => StockStatus::outOfStock(),
str_contains($availability, 'preorder') => StockStatus::preOrder(),
default => null,
};
}
}
return null;
}
private function extractFromButtonState(Crawler $crawler): StockStatus
{
// Если кнопка "Купить" заблокирована — нет в наличии
$buyButton = $crawler->filter('button[data-action="buy"], .add-to-cart-btn, #add-to-cart');
if ($buyButton->count() > 0) {
$isDisabled = $buyButton->first()->attr('disabled') !== null
|| str_contains($buyButton->first()->attr('class') ?? '', 'disabled');
return $isDisabled ? StockStatus::outOfStock() : StockStatus::inStock();
}
// Текстовый анализ всей страницы как fallback
$pageText = mb_strtolower($crawler->text());
foreach ($this->outOfStockPatterns as $pattern) {
if (preg_match($pattern, $pageText)) return StockStatus::outOfStock();
}
foreach ($this->inStockPatterns as $pattern) {
if (preg_match($pattern, $pageText)) return StockStatus::inStock();
}
return StockStatus::unknown();
}
public function extractQuantity(string $html): ?int
{
$crawler = new Crawler($html);
// "Осталось 5 шт", "В наличии: 3"
$patterns = [
'/осталось\s+(\d+)\s*(шт|штук|ед)/ui',
'/в\s+наличии[:\s]+(\d+)/ui',
'/количество[:\s]+(\d+)/ui',
'/(\d+)\s*(шт|штук)\.?\s+в\s+наличии/ui',
];
$text = $crawler->text();
foreach ($patterns as $pattern) {
if (preg_match($pattern, $text, $m)) {
return (int) $m[1];
}
}
return null;
}
}
Модель и история
// app/Models/CompetitorStock.php
class CompetitorStock extends Model
{
protected $casts = [
'in_stock' => 'boolean',
'scraped_at' => 'datetime',
];
protected static function booted(): void
{
static::updated(function (self $model) {
if ($model->wasChanged('in_stock')) {
CompetitorStockChanged::dispatch($model);
}
});
}
}
// database/migrations
Schema::create('competitor_stock_history', function (Blueprint $table) {
$table->id();
$table->foreignId('product_id')->constrained();
$table->foreignId('competitor_id')->constrained();
$table->enum('status', ['in_stock', 'out_of_stock', 'preorder', 'unknown']);
$table->integer('quantity')->nullable();
$table->date('recorded_date');
$table->timestamps();
$table->unique(['product_id', 'competitor_id', 'recorded_date']);
});
Реакция на изменения
// app/Listeners/HandleCompetitorStockChanged.php
class HandleCompetitorStockChanged
{
public function handle(CompetitorStockChanged $event): void
{
$stock = $event->stock;
$product = $stock->product;
// Конкурент закончил товар → уведомляем о возможности поднять цену
if (!$stock->in_stock && $stock->getOriginal('in_stock')) {
$this->notifyPriceOpportunity($product, $stock->competitor);
}
// Конкурент снова завёз товар → сигнал для закупщика
if ($stock->in_stock && !$stock->getOriginal('in_stock')) {
$this->notifyRestocked($product, $stock->competitor);
}
// Если ВСЕ конкуренты закончили → автоматически поднять цену
$allOutOfStock = CompetitorStock::where('product_id', $product->id)
->where('in_stock', true)
->doesntExist();
if ($allOutOfStock && config('repricing.raise_when_competitors_out')) {
$this->applyScarcityPricing($product);
}
}
private function applyScarcityPricing(Product $product): void
{
$rule = $product->repricingRule;
if (!$rule?->scarcity_multiplier) return;
$newPrice = $product->base_price * $rule->scarcity_multiplier;
$product->update(['price' => round($newPrice, 2)]);
}
}
Расписание мониторинга
$schedule->command('monitor:competitor-stock --frequency=high')
->hourly()->withoutOverlapping();
$schedule->command('monitor:competitor-stock --all')
->everyFourHours()->withoutOverlapping();
Сводный отчёт по наличию
// Аналитика: % товаров в наличии у каждого конкурента
$report = CompetitorStock::selectRaw(
'competitor_id,
COUNT(*) as total,
SUM(CASE WHEN in_stock THEN 1 ELSE 0 END) as available,
ROUND(100.0 * SUM(CASE WHEN in_stock THEN 1 ELSE 0 END) / COUNT(*), 1) as availability_pct'
)
->groupBy('competitor_id')
->with('competitor:id,name')
->get();
Срок разработки: мониторинг наличия для 3-5 конкурентов с историей и событиями — 5-7 рабочих дней.







