Разработка парсера цен конкурентов для мониторинга
Мониторинг цен конкурентов решает одну конкретную задачу: знать, когда и насколько конкурент изменил цену, раньше чем это заметят покупатели. Для этого нужен не просто парсер, а система с историей изменений, аналитикой и оповещениями в реальном времени.
Архитектура системы мониторинга
Ключевое отличие от разового парсера — приоритизация товаров. Популярные позиции нужно проверять раз в час, длинный хвост каталога — раз в сутки. Это снижает нагрузку на источник и ускоряет реакцию на важные изменения.
[Scheduler]
├── High priority queue (топ-товары, каждый час)
└── Low priority queue (остальные, раз в сутки)
↓
[Fetcher] → [Parser] → [Change Detector] → [Alert Engine]
↓
[price_history table]
Change Detector сравнивает новую цену с последней записью в истории. При изменении — запись в price_history и событие в очередь алертов. Без изменений — только обновление last_checked_at, чтобы не раздувать историю.
Парсинг цен: технические нюансы
Цены на сайтах бывают представлены по-разному:
- В HTML (простой случай) — CSS-селектор
.product-priceили атрибутdata-price - В JSON-LD (schema.org
Product) — надёжно, не ломается при редизайне - Через XHR API — перехват сетевых запросов через Playwright
- Динамически через JS после загрузки — нужен headless browser
JSON-LD — наиболее стабильный источник для парсинга цен. Многие SEO-оптимизированные магазины добавляют микроразметку schema.org для поисковых роботов:
import * as cheerio from 'cheerio';
interface PriceData {
price: number;
priceSale?: number;
currency: string;
inStock: boolean;
}
function extractPriceFromJsonLd(html: string): PriceData | null {
const $ = cheerio.load(html);
for (const scriptEl of $('script[type="application/ld+json"]').toArray()) {
try {
const data = JSON.parse($(scriptEl).html() ?? '{}');
const product = data['@type'] === 'Product' ? data :
(Array.isArray(data['@graph'])
? data['@graph'].find((n: { '@type': string }) => n['@type'] === 'Product')
: null);
if (product?.offers) {
const offer = Array.isArray(product.offers) ? product.offers[0] : product.offers;
return {
price: parseFloat(offer.price),
currency: offer.priceCurrency ?? 'RUB',
inStock: offer.availability?.includes('InStock') ?? true,
};
}
} catch { continue; }
}
return null;
}
Обработка форматов цен в тексте: "1 299,00 ₽", "$12.99", "€ 9,90" — нормализация через regex. Хранить как DECIMAL(10,2) с отдельным полем currency. Следить за тремя уровнями: цена без скидки (price_original), цена со скидкой (price_sale), цена по карте лояльности (часто третья скрытая цена).
База данных
CREATE TABLE monitored_products (
id SERIAL PRIMARY KEY,
source VARCHAR(100) NOT NULL,
external_id VARCHAR(255) NOT NULL,
title TEXT,
url TEXT NOT NULL,
priority SMALLINT DEFAULT 5, -- 1=высший, 10=низший
check_interval INT DEFAULT 360, -- минуты
last_checked_at TIMESTAMPTZ,
UNIQUE(source, external_id)
);
CREATE TABLE price_history (
id BIGSERIAL PRIMARY KEY,
product_id INT REFERENCES monitored_products(id),
price DECIMAL(10,2),
price_original DECIMAL(10,2),
in_stock BOOLEAN,
currency VARCHAR(3) DEFAULT 'RUB',
recorded_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX ON price_history(product_id, recorded_at DESC);
-- Быстрый доступ к актуальной цене без JOIN с историей
ALTER TABLE monitored_products
ADD COLUMN current_price DECIMAL(10,2),
ADD COLUMN current_in_stock BOOLEAN;
Детектор изменений
async function processNewPrice(
productId: number,
newPrice: number,
newInStock: boolean
): Promise<{ changed: boolean; delta?: number }> {
const product = await db.monitoredProducts.findById(productId);
const priceChanged = product.currentPrice !== newPrice;
const stockChanged = product.currentInStock !== newInStock;
if (!priceChanged && !stockChanged) {
// Только обновить время проверки
await db.monitoredProducts.update(productId, { lastCheckedAt: new Date() });
return { changed: false };
}
// Записать в историю
await db.priceHistory.create({
productId,
price: newPrice,
inStock: newInStock,
recordedAt: new Date(),
});
// Обновить текущие значения
await db.monitoredProducts.update(productId, {
currentPrice: newPrice,
currentInStock: newInStock,
lastCheckedAt: new Date(),
});
const delta = product.currentPrice
? ((newPrice - product.currentPrice) / product.currentPrice) * 100
: 0;
return { changed: true, delta };
}
Алерты и пороги
Конфигурируемые правила срабатывания:
- Цена снизилась более чем на X% (например, 5% или 10%)
- Цена опустилась ниже вашей цены на аналогичный товар
- Товар появился или исчез из наличия
- Цена изменилась у N+ конкурентов одновременно (признак рыночного сдвига)
- Цена достигла исторического минимума за последние 90 дней
async function checkAlertRules(productId: number, delta: number): Promise<void> {
const rules = await db.alertRules.findAll({ productId, active: true });
for (const rule of rules) {
const triggered =
(rule.type === 'price_drop_percent' && delta < -rule.threshold) ||
(rule.type === 'below_my_price' && await isPriceBelowMyPrice(productId)) ||
(rule.type === 'out_of_stock' && newInStock === false);
if (triggered) {
await sendAlert(rule, productId, delta);
}
}
}
Доставка: Telegram-бот (мгновенно через Bot API), email-дайджест (раз в день), webhook в систему управления ценами (для автоматической реакции).
Дашборд аналитики
Минимально необходимые экраны:
Таблица мониторинга — все отслеживаемые товары с текущей ценой конкурента, вашей ценой, разницей в процентах и трендом (стрелка вверх/вниз).
График цен — цена конкурента vs ваша цена за выбранный период. Recharts LineChart с двумя линиями и маркерами изменений.
Лента алертов — последние 50 изменений с фильтрацией по источнику и типу изменения.
Быстрая реализация дашборда — Metabase подключённый к PostgreSQL. Кастомный React-интерфейс с Recharts нужен если дашборд встраивается в существующую систему управления ассортиментом.
Сроки и масштаб
| Масштаб | Источников | Товаров | Срок |
|---|---|---|---|
| Малый | 1–3 | до 10k | 5–8 дней |
| Средний | 3–10 | 10k–100k | 2–3 недели |
| Крупный | 10+ | 100k+ | 4–6 недель |
Для 100k+ товаров с историей за год — ClickHouse вместо PostgreSQL для хранения price_history: аналитические запросы (агрегация за период, поиск минимума) на больших объёмах работают на порядок быстрее. PostgreSQL остаётся для оперативных данных и конфигурации.







