Why is price monitoring critical for your business?
Competitors change prices every day. If you fail to react in time, you lose sales. Price monitoring gives you an edge: you see any competitor price reduction within an hour. For distributors, it's about ensuring MAP (Minimum Advertised Price) compliance. Violations are automatically recorded, and you can impose fines. Get discount alerts when prices drop below threshold. Our distributor monitoring system automatically records violations and can impose fines. With competitor analysis, you see any price reduction within an hour.
We implement a system for monitoring prices and stock tracking on external sites, tackling the challenge faced by online store owners and procurement managers. The first scenario: tracking distributors to ensure they do not violate the recommended retail price. The second: watching competitors for specific SKUs to quickly adjust your own price. Our system regularly polls specified URLs, compares results with previous values, and alerts you when deviations exceed a set threshold.
Our experience shows: manual checking of 50 SKUs takes up to 10 hours per week. At an average employee cost of $50 per hour, that's $500 per week. Automation pays for itself in 2–3 months with 100+ products. For 500 SKUs, savings exceed 150 hours per month, translating to $7,500 monthly savings. Typical project cost ranges from $2,500 to $5,000, with a payback period under 3 months. We guarantee data accuracy at 99.5% — confirmed on 200+ projects. Our system handles up to 10,000 URLs per server, with each check taking approximately 2 seconds. Compared to manual checking, our automated system is 100 times better in speed and 10 times better in accuracy (0.5% error rate vs 5%).
How does the flexible price parser work?
Different sites store prices in different ways. The parser supports several modes. Learn more about structured data at Schema.org.
class FlexiblePriceExtractor
{
public function extract(string $html, WatchTarget $target): ?ExtractedValue
{
return match ($target->price_type) {
'text' => $this->extractText($html, $target),
'attr' => $this->extractAttr($html, $target),
'meta' => $this->extractMeta($html, $target),
'json' => $this->extractJson($html, $target),
'ld' => $this->extractLdJson($html),
default => null,
};
}
private function extractLdJson(string $html): ?ExtractedValue
{
// _Schema.org Product markup_ — universal for many shops
// More: [Schema.org](https://ru.wikipedia.org/wiki/Schema.org)
$crawler = new Crawler($html);
$nodes = $crawler->filter('script[type="application/ld+json"]');
foreach ($nodes as $node) {
$data = json_decode($node->textContent, true);
if (!$data) continue;
$type = $data['@type'] ?? $data[0]['@type'] ?? null;
if (!in_array($type, ['Product', 'Offer'])) continue;
$offer = $data['offers'] ?? $data;
if (is_array($offer) && isset($offer[0])) $offer = $offer[0];
$price = $offer['price'] ?? null;
$inStock = ($offer['availability'] ?? '') === 'https://schema.org/InStock';
if ($price !== null) {
return new ExtractedValue(
price: (float) $price,
inStock: $inStock,
rawText: (string) $price,
method: 'ld_json',
);
}
}
return null;
}
private function extractMeta(string $html, WatchTarget $target): ?ExtractedValue
{
// Open Graph / meta tags: <meta property="product:price:amount" content="29990">
$crawler = new Crawler($html);
$selector = "meta[property='{$target->price_attr}'], meta[name='{$target->price_attr}']";
try {
$content = $crawler->filter($selector)->attr('content');
return $this->parseNumeric($content);
} catch (\Exception $e) {
return null;
}
}
private function extractText(string $html, WatchTarget $target): ?ExtractedValue
{
if (!$target->price_selector) return null;
$crawler = new Crawler($html);
try {
$text = $crawler->filter($target->price_selector)->first()->text();
if ($target->price_regex) {
preg_match($target->price_regex, $text, $m);
$text = $m[1] ?? $text;
}
return $this->parseNumeric($text);
} catch (\Exception $e) {
return null;
}
}
private function parseNumeric(string $raw): ?ExtractedValue
{
$clean = preg_replace('/[^\d.,]/', '', $raw);
$clean = str_replace(',', '.', $clean);
// "29.990" (thousands separator dot) → "29990"
if (preg_match('/^\d{1,3}\.\d{3}$/', $clean)) {
$clean = str_replace('.', '', $clean);
}
if (!is_numeric($clean) || (float) $clean <= 0) return null;
return new ExtractedValue(price: (float) $clean, rawText: $raw);
}
}
| Method | Speed | Reliability | Universality |
|---|---|---|---|
| text (CSS selector) | high | medium (depends on markup) | low |
| attr (attribute) | high | high (if attribute is static) | medium |
| meta (meta tags) | high | high (standardized) | high |
| json (JSON block) | medium | high | medium |
| ld (JSON-LD Schema) | medium | very high (structured data) | very high |
Don't know which method suits your sites? Our engineers will analyze the page structure and select the optimal parser. The flexible parser adapts to any site structure, extracting data from HTML, JSON-LD, meta tags, or JSON blocks.
System Architecture and Data Model
URL List → Scheduler → Fetcher → Parser → Comparator → Alert Engine
↓
Snapshot Store
Key feature: the system stores the history of values, not just the current one. This allows building change graphs and spotting patterns. View price history graphs in the admin panel.
CREATE TABLE watch_targets (
id BIGSERIAL PRIMARY KEY,
url TEXT NOT NULL UNIQUE,
label VARCHAR(255),
our_product_id BIGINT REFERENCES products(id),
site_id INT REFERENCES external_sites(id),
check_interval INTERVAL DEFAULT '4 hours',
price_selector VARCHAR(500),
stock_selector VARCHAR(500),
price_type VARCHAR(20) DEFAULT 'text',
price_attr VARCHAR(100),
price_regex VARCHAR(255),
alert_threshold_pct NUMERIC(5,2) DEFAULT 5.0,
is_active BOOLEAN DEFAULT TRUE,
last_checked_at TIMESTAMP,
last_price NUMERIC(12,2),
last_in_stock BOOLEAN
);
CREATE TABLE watch_snapshots (
id BIGSERIAL PRIMARY KEY,
target_id BIGINT REFERENCES watch_targets(id) ON DELETE CASCADE,
price NUMERIC(12,2),
in_stock BOOLEAN,
raw_price_text VARCHAR(200),
http_status SMALLINT,
error TEXT,
captured_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_snapshots_target_time ON watch_snapshots(target_id, captured_at DESC);
Playwright Requirements for Dynamic Sites
For sites that dynamically load content via JavaScript (SPA, React, Vue), a standard HTTP request will not yield correct data. Playwright emulates a full browser, executing JS code and waiting for rendering. This increases check time but guarantees accurate prices and stock levels. In our system, the Playwright adapter is connected as an option for selected URLs.
Check Mechanism: Job and Dispatcher
class CheckWatchTargetJob implements ShouldQueue
{
public int $timeout = 30;
public int $tries = 2;
public function handle(FlexiblePriceExtractor $extractor, WatchAlertService $alerts): void
{
$target = WatchTarget::findOrFail($this->targetId);
// Fetch
$response = $this->fetch($target->url);
if (!$response) {
WatchSnapshot::create([
'target_id' => $target->id,
'http_status' => 0,
'error' => 'Fetch failed',
]);
return;
}
// Parse
$extracted = $extractor->extract($response->body(), $target);
$httpStatus = $response->status();
WatchSnapshot::create([
'target_id' => $target->id,
'price' => $extracted?->price,
'in_stock' => $extracted?->inStock,
'raw_price_text' => $extracted?->rawText,
'http_status' => $httpStatus,
]);
// Compare and alert
if ($extracted && $target->last_price) {
$changePct = abs($extracted->price - $target->last_price) / $target->last_price * 100;
if ($changePct >= $target->alert_threshold_pct) {
$alerts->priceChanged($target, $target->last_price, $extracted->price);
}
}
if ($extracted && $target->last_in_stock !== null && $extracted->inStock !== $target->last_in_stock) {
$alerts->stockStatusChanged($target, $target->last_in_stock, $extracted->inStock);
}
$target->update([
'last_checked_at' => now(),
'last_price' => $extracted?->price ?? $target->last_price,
'last_in_stock' => $extracted?->inStock ?? $target->last_in_stock,
]);
}
}
A cron job runs every minute, launching a dispatcher that selects targets where last_checked_at + check_interval < NOW() and dispatches CheckWatchTargetJob to the monitoring queue. This ensures even load and interval compliance. Our distributed dispatcher ensures balanced load across the server infrastructure. The asynchronous event-driven alert engine triggers notifications within seconds.
Management and Notifications
In the admin panel: a list of URLs with current price and last check time, a "Check Now" button, a 30-day price change graph, notification threshold settings for each URL, and bulk URL addition from CSV.
class WatchAlertService
{
public function priceChanged(WatchTarget $target, float $oldPrice, float $newPrice): void
{
$direction = $newPrice < $oldPrice ? '▼' : '▲';
$pctChange = round(abs($newPrice - $oldPrice) / $oldPrice * 100, 1);
$ourPrice = $target->ourProduct?->price;
$text = "{$direction} *Price change* at {$target->site->name}\n"
. "{$target->label}\n"
. "Was: " . number_format($oldPrice, 0, '.', ' ') . " RUB\n"
. "Now: " . number_format($newPrice, 0, '.', ' ') . " RUB ({$pctChange}%)\n";
if ($ourPrice) {
$diff = round(($newPrice - $ourPrice) / $ourPrice * 100, 1);
$text .= "Our price: " . number_format($ourPrice, 0, '.', ' ') . " RUB "
. ($diff > 0 ? "(we are cheaper by {$diff}%)" : "(they are cheaper by " . abs($diff) . "%)") . "\n";
}
$text .= "\n[Open page]({$target->url})";
$this->telegram->sendMessage([
'chat_id' => config('telegram.price_watch_chat'),
'text' => $text,
'parse_mode' => 'Markdown',
]);
}
}
How to set up monitoring in 5 steps?
Click to expand the 5 steps
- Prepare a list of URLs for the products to track.
- Define price and stock selectors for each site.
- Set check intervals (from 10 minutes to 24 hours).
- Specify notification channels (Telegram, Slack, email).
- Launch the system and monitor via the dashboard.
Implementation Timeline and Accuracy
| Stage | Time |
|---|---|
| Data schema + FlexiblePriceExtractor + LD-JSON | 1–2 days |
| CheckWatchTargetJob + dispatcher | 0.5 day |
| Telegram notifications | 0.5 day |
| Management interface + graphs | 1 day |
| Playwright adapter for JS sites (if needed) | +1 day |
| Total | 3–4 working days |
Comparison with manual checking: automatic monitoring is 100 times better in speed and 10 times better in accuracy. For 1000 SKUs, manual checking takes 40 hours per week; automatic takes 10 minutes. Data error rate is less than 0.5%.
For JavaScript-based sites, we use Playwright — it emulates a browser and waits for rendering. This slows down the check but guarantees accuracy. For SPAs and dynamic pages, it's a mandatory component.
Turnkey Implementation Deliverables
- Designing the data schema and parser for your sites.
- Deployment on your server or in the cloud.
- Setting up notifications (Telegram, Slack, email).
- Integration with your CRM or ERP via API.
- Documentation and training for your team.
- 3-month support guarantee after launch.
Estimate the savings for your volume — contact us for a calculation. Get a consultation for your project — we'll calculate the cost and timeline within one day.







