מנהלים מבזבזים עד 3 שעות ביום בהעתקת נתונים מלוחות מחוונים של ספקים. הזנה ידנית מובילה לשיעור שגיאות של 15%, ומחירים מיושנים גורמים להפסדים ישירים. התוצאה: עסקאות אבודות ולקוחות לא מרוצים. אנו הופכים את איסוף המוצרים, המחירים והמלאי לאוטומטי ללא התערבות אנושית. במשך שנות פעילותנו בשוק, סיפקנו למעלה מ-30 פרויקטים, והפחתנו את זמן איסוף הנתונים ללקוחות ב-80%. לדוגמה, חנות מקוונת עם 5,000 פריטים (SKU) השקיעה 40 שעות חודשיות בעדכון מחירים; לאחר הטמעת הפרוסר, התהליך אורך 4 שעות באופן אוטונומי. חיסכון תקציבי של עד 30%, שבמונחים כספיים יכול להסתכם, למשל, ב-$2.2k–3.1k לשנה.
לפי ההגדרה, גירוד אתרים הוא חילוץ אוטומטי של נתונים מהאינטרנט. כך זה עובד בפועל.
מדוע איסוף נתונים ידני הוא יקר?
80% מהזמן מושקע במשימות מונוטוניות. 15% מהרשומות מכילות שגיאות עקב גורם אנושי. לקוחות אובדים עקב מחירים מיושנים או מוצרים חסרי מלאי. אוטומציה מפחיתה עלויות ב-30% ומבטלת לחלוטין שגיאות הזנה.
אילו נתונים אנו אוספים
| סוג נתונים | דוגמה | מקור |
|---|---|---|
| שדות בסיסיים | שם, מחיר, SKU | דף רשימת מוצרים |
| מורחבים | תיאור, מפרטים, תמונות | דף פרטי מוצר |
| דינמיים | מלאי, מצב זמינות | בקשות AJAX או DOM |
כיצד הפרוסר עובד וכיצד אנו עוקפים הגנות?
ארכיטקטורת פתרון טיפוסית:
- מתזמן (cron / Laravel Horizon) מריץ משימות לפי לוח זמנים.
- לקוח HTTP (Guzzle / Playwright) מביא דפי ספקים.
- פרוסר מחלץ נתונים באמצעות בוררי CSS או XPath.
- מנרמל מביא מחירים, תאריכים ומטבעות לפורמט אחיד.
- מסיר כפילויות בודק מוצרים לפי SKU ומעדכן או יוצר רשומות.
- התראות על שגיאות או שינויי מחירים משמעותיים.
ספקים מודרניים משתמשים ב-Cloudflare, CAPTCHA וטעינה דינמית. אנו מיישמים סיבוב פרוקסי מגורים, אמולציית דפדפן Playwright/Puppeteer עם תנועות עכבר אקראיות, השהיות של 500 אלפיות השנייה עד 2 שניות, והחלפת User-Agent. אם האתר משתמש ב-JavaScript, Playwright טוען את הדף כמו דפדפן אמיתי ומחזיר את ה-DOM המוכן.
מה עדיף: Guzzle או Playwright?
| קריטריון | Guzzle (PHP) | Playwright (Node) |
|---|---|---|
| מהירות | גבוהה (ללא דפדפן) | בינונית (הפעלת דפדפן) |
| טיפול ב-JS | לא נתמך | אמולציה מלאה |
| משאבים | מינימליים | דורש יותר זיכרון RAM |
| מורכבות גירוד | מספיק לאתרים סטטיים | נדרש לאתרי SPA |
Guzzle מעבד דפים סטטיים פי 2 מהר יותר מ-Playwright. גישה משולבת — Guzzle לסטטי, Playwright ל-SPA — מניבה את התוצאה הטובה ביותר.
יישום טכני
פרוסר בסיסי ב-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)];
}
} משימה לעיבוד רקע
// app/Jobs/ScrapeSupplierProducts.php
class ScrapeSupplierProducts implements ShouldQueue
{
use Queueable;
public int $tries = 2;
public int $timeout = 300;
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));
}
}
} Playwright לאתרים כבדי JS
// 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 באמצעות // 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)]; } } או מיקרוסרוויס 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();
$schedule->command('scraper:supplier --supplier=1 --prices-only')
->everyFourHours()
->withoutOverlapping();
} דוגמה למבנה נתונים לגירוד
| שדה | סוג | דוגמה |
|---|---|---|
| sku | string | ART-12345 |
| title | string | Smartphone XYZ |
| price | float | 19999.99 |
| in_stock | bool | true |
תהליך הפיתוח ולוחות זמנים
- ניתוח (1-2 ימים) — חקר מבנה אתר הספק, זיהוי סוגי הגנה, הסכמה על שדות.
- עיצוב (יום אחד) — בחירת מחסנית טכנולוגית, תכנון ארכיטקטורה (תורים, לוח זמנים, סכמת נתונים).
- יישום (3-7 ימים) — כתיבת פרוסרים, מנרמלים, הסרת כפילויות, אינטגרציה עם המערכת שלך.
- בדיקות (1-2 ימים) — הרצה על נתונים אמיתיים, אימות נכונות ועמידות.
- פריסה (יום אחד) — הגדרת סביבה, ניטור, התראות, מסירת תיעוד.
לוחות זמנים משוערים
- פרוסר בסיסי לספק אחד (HTML סטטי, 5-10 שדות): בין 3 ל-5 ימי עסקים.
- פרוסר עם הגנה מורכבת או רינדור JS: בין 7 ל-14 ימים.
- אינטגרציה עם מספר ספקים לצינור נתונים אחד: החל מ-10 ימי עסקים.
העלות מחושבת באופן אישי לאחר ביקורת על אתרי היעד.
מה כלול בתוצאה
- פרוסר עם קוד פתוח (PHP + Laravel או Node.js).
- אינטגרציה עם מסד הנתונים שלך באמצעות API או ייבוא ישיר.
- הגדרת לוח זמנים וניטור.
- תיעוד להשקה ותחזוקה.
- אחריות ל-30 יום על פעילות יציבה.
טעויות נפוצות בפיתוח פרוסרים
- התעלמות ממגבלת קצב — האתר חוסם את כתובת ה-IP שלך לאחר 100 בקשות. פתרון: הוספת השהיות אקראיות וסיבוב.
- קידוד קשיח של מבנה HTML — שינוי עיצובי קטן שובר את הפרוסר. פתרון: שימוש ב-data-attributes או XPath.
- חוסר טיפול בשגיאות — כשל ספק אחד מפיל את כל התור. פתרון: בידוד משימות והוספת ניסיונות חוזרים.
- אחסון כל הנתונים בטבלה אחת ללא נורמליזציה — כפילויות ופערים. פתרון: שימוש ב-SKU כמפתח ייחודי.
צרו קשר לייעוץ — נעריך את הפרויקט שלכם ונציע פתרון סוהר. הזמינו פיתוח פרוסר וקבלו פתרון מוכן שחוסך לכם זמן וכסף.







