Разработка бота-парсера товаров с сайтов поставщиков
Парсер сайтов поставщиков автоматизирует получение данных о товарах, ценах и остатках без ручного копирования. Результат — структурированные данные в вашей базе, обновляемые по расписанию.
Архитектура парсера
Scheduler (cron / Horizon)
→ Scraper Job
→ HTTP Client (Guzzle / curl)
→ HTML Parser (Symfony DomCrawler / Goutte)
→ Data Normalizer
→ Duplicate Checker
→ Product Repository
→ Notification (если ошибки)
Стек инструментов
| Задача | Инструмент |
|---|---|
| HTTP-запросы | Guzzle 7 |
| Парсинг HTML | Symfony DomCrawler + CSS Selector |
| JS-сайты | Puppeteer (Node) / Playwright |
| Очереди | Laravel Queue + Redis |
| Прокси | Rotating proxy pool |
| Хранение | PostgreSQL / MySQL |
Базовый парсер на PHP
// app/Services/Scrapers/SupplierScraper.php
use GuzzleHttp\Client;
use Symfony\Component\DomCrawler\Crawler;
class SupplierScraper
{
private Client $client;
public function __construct(
private string $baseUrl,
private array $proxyPool = []
) {
$this->client = new Client([
'timeout' => 15,
'connect_timeout' => 5,
'headers' => [
'User-Agent' => $this->randomUserAgent(),
'Accept-Language' => 'ru-RU,ru;q=0.9',
'Accept' => 'text/html,application/xhtml+xml',
],
]);
}
public function scrapeProductList(string $categoryUrl): array
{
$html = $this->fetchWithRetry($categoryUrl);
$crawler = new Crawler($html);
return $crawler->filter('.product-card')->each(function (Crawler $node) {
return [
'url' => $node->filter('a.product-link')->attr('href'),
'title' => trim($node->filter('.product-title')->text()),
'price' => $this->parsePrice($node->filter('.price')->text()),
'sku' => $node->filter('[data-sku]')->attr('data-sku'),
];
});
}
public function scrapeProductDetail(string $productUrl): array
{
$html = $this->fetchWithRetry($this->baseUrl . $productUrl);
$crawler = new Crawler($html);
return [
'title' => $crawler->filter('h1.product-name')->text(),
'description' => $crawler->filter('.description')->html(),
'price' => $this->parsePrice($crawler->filter('.current-price')->text()),
'images' => $crawler->filter('.gallery img')->each(
fn(Crawler $img) => $img->attr('src')
),
'specs' => $this->extractSpecs($crawler),
'in_stock' => $crawler->filter('.in-stock')->count() > 0,
'sku' => $crawler->filter('[itemprop="sku"]')->text(''),
];
}
private function extractSpecs(Crawler $crawler): array
{
$specs = [];
$crawler->filter('.specs-table tr')->each(function (Crawler $row) use (&$specs) {
$key = trim($row->filter('td:first-child')->text(''));
$val = trim($row->filter('td:last-child')->text(''));
if ($key && $val) {
$specs[$key] = $val;
}
});
return $specs;
}
private function fetchWithRetry(string $url, int $attempts = 3): string
{
$proxy = $this->proxyPool ? $this->randomProxy() : null;
for ($i = 0; $i < $attempts; $i++) {
try {
$options = $proxy ? ['proxy' => $proxy] : [];
$response = $this->client->get($url, $options);
return (string) $response->getBody();
} catch (\Exception $e) {
if ($i === $attempts - 1) throw $e;
sleep(rand(2, 5));
}
}
}
private function parsePrice(string $text): float
{
// Убираем пробелы, валютные символы, заменяем запятые
return (float) preg_replace('/[^\d.,]/', '', str_replace(',', '.', $text));
}
private function randomUserAgent(): string
{
$agents = [
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15',
];
return $agents[array_rand($agents)];
}
}
Job для фоновой обработки
// app/Jobs/ScrapeSupplierProducts.php
class ScrapeSupplierProducts implements ShouldQueue
{
use Queueable;
public int $tries = 2;
public int $timeout = 300; // 5 минут на категорию
public function __construct(
private int $supplierId,
private string $categoryUrl
) {}
public function handle(
SupplierScraper $scraper,
ProductImportService $importer
): void {
$products = $scraper->scrapeProductList($this->categoryUrl);
foreach ($products as $productPreview) {
// Детали каждого товара — отдельная задача
ScrapeSupplierProductDetail::dispatch(
$this->supplierId,
$productPreview['url']
)->onQueue('scraper-detail');
// Задержка между запросами: не флудим сайт
usleep(rand(500000, 1500000)); // 0.5–1.5 сек
}
}
}
Обход защиты и капчи
Ротация прокси:
// config/scraper.php
return [
'proxy_pool' => [
'http://user:[email protected]:3128',
'http://user:[email protected]:3128',
],
'request_delay_ms' => [500, 2000], // min, max
'rotate_user_agent' => true,
];
Playwright для JS-сайтов:
Если сайт требует выполнения JavaScript:
// scraper/playwright-worker.js
const { chromium } = require('playwright');
async function scrapeProduct(url) {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)...',
viewport: { width: 1366, height: 768 },
});
const page = await context.newPage();
await page.goto(url, { waitUntil: 'networkidle' });
const product = await page.evaluate(() => ({
title: document.querySelector('h1')?.textContent,
price: document.querySelector('.price')?.textContent,
images: [...document.querySelectorAll('.gallery img')].map(i => i.src),
}));
await browser.close();
return product;
}
PHP вызывает Node-процесс через proc_open или через HTTP-микросервис.
Дедупликация и обновление
// app/Services/ProductImportService.php
class ProductImportService
{
public function upsert(int $supplierId, array $data): void
{
$product = SupplierProduct::updateOrCreate(
[
'supplier_id' => $supplierId,
'supplier_sku' => $data['sku'],
],
[
'title' => $data['title'],
'price' => $data['price'],
'in_stock' => $data['in_stock'],
'description' => $data['description'],
'images' => json_encode($data['images']),
'specs' => json_encode($data['specs']),
'scraped_at' => now(),
]
);
// Уведомить о значительном изменении цены
if ($product->wasChanged('price')) {
$change = abs($product->price - $product->getOriginal('price'));
if ($change / $product->getOriginal('price') > 0.05) {
PriceChangedNotification::dispatch($product);
}
}
}
}
Расписание запусков
// app/Console/Kernel.php
protected function schedule(Schedule $schedule): void
{
// Полный обход каталога — раз в сутки ночью
$schedule->command('scraper:supplier --supplier=1')
->dailyAt('03:00')
->withoutOverlapping();
// Только цены и остатки — каждые 4 часа
$schedule->command('scraper:supplier --supplier=1 --prices-only')
->everyFourHours()
->withoutOverlapping();
}
Срок разработки
Базовый парсер одного поставщика (статический HTML, 5-10 полей): 3-5 рабочих дней, включая настройку очередей, расписания и базового мониторинга.







