Competitor Price Monitoring Bot with Reports

Our company is engaged in the development, support and maintenance of sites of any complexity. From simple one-page sites to large-scale cluster systems built on micro services. Experience of developers is confirmed by certificates from vendors.
Development and maintenance of all types of websites:
Informational websites or web applications
Business card websites, landing pages, corporate websites, online catalogs, quizzes, promo websites, blogs, news resources, informational portals, forums, aggregators
E-commerce websites or web applications
Online stores, B2B portals, marketplaces, online exchanges, cashback websites, exchanges, dropshipping platforms, product parsers
Business process management web applications
CRM systems, ERP systems, corporate portals, production management systems, information parsers
Electronic service websites or web applications
Classified ads platforms, online schools, online cinemas, website builders, portals for electronic services, video hosting platforms, thematic portals

These are just some of the technical types of websites we work with, and each of them can have its own specific features and functionality, as well as be customized to meet the specific needs and goals of the client.

Showing 1 of 1 servicesAll 2065 services
Competitor Price Monitoring Bot with Reports
Medium
~3-5 business days
FAQ
Our competencies:
Development stages
Latest works
  • image_web-applications_feedme_466_0.webp
    Development of a web application for FEEDME
    1161
  • image_ecommerce_furnoro_435_0.webp
    Development of an online store for the company FURNORO
    1041
  • image_crm_enviok_479_0.webp
    Development of a web application for Enviok
    822
  • image_crm_chasseurs_493_0.webp
    CRM development for Chasseurs
    847
  • image_website-sbh_0.png
    Website development for SBH Partners
    999
  • image_website-_0.png
    Website development for Red Pear
    451

Developing a Bot for Competitor Price Monitoring With Reports

Competitor price monitoring is not a one-time export but a continuous data collection process with subsequent analytics. The bot should track specific products on specific websites, store price history, and alert on significant changes. Without automation, this work is done manually, consuming hours daily and providing stale data.

Architecture

Scheduler (cron) → Scraper Workers → Price DB → Analytics → Reports/Alerts

Key components:

  • Scraper — fetches HTML or JSON from competitor site
  • Parser — extracts price from content
  • Storage — stores change history
  • Analyzer — calculates deltas, trends
  • Notifier — sends reports to Telegram/Email

Data Schema

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 selector for price
    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 rubles" before parsing
    captured_at     TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_snapshots_monitored_captured
    ON price_snapshots(monitored_id, captured_at DESC);

Scraper With User-Agent and Proxy Rotation

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' => 'en-US,en;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 — delay before retry
            sleep(rand(30, 60));
            return null;
        }

        if (!$response->successful()) {
            Log::warning("Scraper failed: {$url}", ['status' => $response->status()]);
            return null;
        }

        return $response->body();
    }
}

For JavaScript-rendered sites, use Playwright API or 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',  // wait for element
            'options' => ['timeout' => 20000],
        ]);

        return $response->successful() ? $response->body() : null;
    }
}

Price Parser

class PriceParser
{
    public function parse(string $html, MonitoredProduct $config): ?ParsedPrice
    {
        $crawler = new Symfony\Component\DomCrawler\Crawler($html);

        // Try CSS selector from config
        if ($config->selector) {
            try {
                $text = $crawler->filter($config->selector)->first()->text();
                return $this->extractPrice($text);
            } catch (\Exception $e) {
                // Selector didn't work — fallback to heuristics
            }
        }

        // Heuristic search by typical patterns
        $priceSelectors = [
            '[itemprop="price"]',
            '.price__current',
            '.product-price',
            '[data-price]',
            '.js-price',
        ];

        foreach ($priceSelectors as $selector) {
            try {
                $node = $crawler->filter($selector)->first();
                if ($node->count()) {
                    // Try data-price attribute first
                    $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
    {
        // Remove spaces, replace comma with period
        $normalized = preg_replace('/[^\d,.]/', '', $text);
        $normalized = str_replace(',', '.', $normalized);

        // Remove thousands separators: "29.990" → "29990" (if no decimals)
        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 for Checking Price

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,
        ]);

        // Alert on significant change
        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(),
        ]);
    }
}

Reports

Daily report is generated by schedule and sent to Telegram group:

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();
    }
}

Check Schedule

// High-priority competitors — every 2 hours
$schedule->command('monitor:check --priority=high')->everyTwoHours();

// Others — every 6 hours
$schedule->command('monitor:check --priority=normal')->everySixHours();

// Report — 9:00 AM daily
$schedule->job(new DailyPriceReportJob)->dailyAt('09:00');

Timeline

  • Scraper + PriceParser + data schema: 1–2 days
  • Playwright adapter for JS sites: +1 day
  • Job system + scheduling: 0.5 days
  • Telegram + Email reports: 1 day
  • Competitor list management UI in admin: 1 day

Total: 4–5 business days.