Разработка бота-парсера наличия товаров у конкурентов

Наша компания занимается разработкой, поддержкой и обслуживанием сайтов любой сложности. От простых одностраничных сайтов до масштабных кластерных систем построенных на микро сервисах. Опыт разработчиков подтвержден сертификатами от вендоров.
Разработка и обслуживание любых видов сайтов:
Информационные сайты или веб-приложения
Сайты визитки, landing page, корпоративные сайты, онлайн каталоги, квиз, промо-сайты, блоги, новостные ресурсы, информационные порталы, форумы, агрегаторы
Сайты или веб-приложения электронной коммерции
Интернет-магазины, B2B-порталы, маркетплейсы, онлайн-обменники, кэшбэк-сайты, биржи, дропшиппинг-платформы, парсеры товаров
Веб-приложения для управления бизнес-процессами
CRM-системы, ERP-системы, корпоративные порталы, системы управления производством, парсеры информации
Сайты или веб-приложения электронных услуг
Доски объявлений, онлайн-школы, онлайн-кинотеатры, конструкторы сайтов, порталы предоставления электронных услуг, видеохостинги, тематические порталы

Это лишь некоторые из технических типов сайтов, с которыми мы работаем, и каждый из них может иметь свои специфические особенности и функциональность, а также быть адаптированным под конкретные потребности и цели клиента

Предлагаемые услуги
Показано 1 из 1 услугВсе 2065 услуг
Разработка бота-парсера наличия товаров у конкурентов
Средняя
~3-5 рабочих дней
Часто задаваемые вопросы
Наши компетенции:
Этапы разработки
Последние работы
  • image_website-b2b-advance_0.png
    Разработка сайта компании B2B ADVANCE
    1227
  • image_web-applications_feedme_466_0.webp
    Разработка веб-приложения для компании FEEDME
    1164
  • image_websites_belfingroup_462_0.webp
    Разработка веб-сайта для компании БЕЛФИНГРУПП
    859
  • image_ecommerce_furnoro_435_0.webp
    Разработка интернет магазина для компании FURNORO
    1074
  • image_crm_enviok_479_0.webp
    Разработка веб-приложения для компании Enviok
    829
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Разработка веб-сайта для компании ФИКСПЕР
    839

Разработка бота-парсера наличия товаров у конкурентов

Данные об остатках конкурентов открывают несколько бизнес-возможностей: повысить цену, когда у конкурента кончился товар; сигнализировать менеджеру о пополнении запасов; показывать на сайте "Осталось мало" когда у конкурентов пусто. Бот мониторит наличие по расписанию и реагирует на изменения.

Что отслеживается

  • Наличие/отсутствие — есть ли товар в продаже
  • Количество — если сайт показывает остаток ("Осталось 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 рабочих дней.