---
title: "وب اسکرپینگ با PHP: cURL، Guzzle و تنظیم پروکسی"
description: "هستهٔ اسکرپینگ در PHP ارسال درخواست با cURL و تجزیه با DOM و XPath است. Guzzle، تنظیم پروکسی، robots.txt و تلاش دوباره را با کد آزموده توضیح می‌دهیم."
url: https://proxynet.io/fa/blog/php-web-scraping
date: 2026-09-19
author: "Acar Diveroli"
category: "آموزش‌ها, وب اسکرپینگ"
lang: fa
---

# وب اسکرپینگ با PHP: cURL، Guzzle و تنظیم پروکسی

فهرست قیمت یک تأمین‌کننده هفته‌ای دو بار تغییر می‌کند و شما آن فهرست را دستی در پنل خودتان وارد می‌کنید. سایت نه رابط برنامه‌نویسی دارد و نه فایلی برای دانلود؛ داده فقط داخل صفحهٔ HTML است. پروژهٔ شما با PHP نوشته شده، پس راه‌حل را هم در سمت PHP می‌جویید: روی همان سرور اجرا شود، در همان پایگاه داده بنویسد و شبی یک بار با cron راه بیفتد.

در این نوشته توضیح می‌دهیم چگونه با ابزارهای خود PHP از یک صفحه دادهٔ ساخت‌یافته بیرون بکشید. به ترتیب: ارسال درخواست با cURL، تجزیهٔ HTML با `DOMDocument` و XPath، تجزیه‌گر تازهٔ HTML5 در ⁦PHP 8.4⁩، مدیریت خطا و درخواست‌های هم‌زمان با Guzzle، تنظیم پروکسی (`CURLOPT_PROXY`، SOCKS5 و گزینهٔ `proxy` در Guzzle)، خواندن robots.txt، انتظار و تلاش دوباره. در پایان نمونه‌ای کامل هست که داده را با PDO در پایگاه داده می‌نویسد. همهٔ کدها با ⁦PHP 8.4⁩ روی `books.toscrape.com` و از مسیر یک پروکسی آزمایشی محلی اجرا شدند.

> **نکته: پاسخ کوتاه**
>
> اسکرپینگ در PHP دو گام است: دانلود صفحه با توابع `curl_*` (یا با Guzzle) و تجزیهٔ HTML دریافتی با `DOMDocument` + XPath یا با Symfony DomCrawler. `file_get_contents` و عبارت باقاعده در یک آزمایش کوچک کار می‌کنند اما از پس کد وضعیت، مهلت زمانی و تگ‌های تودرتو برنمی‌آیند. پروکسی در cURL با `CURLOPT_PROXY` و `CURLOPT_PROXYUSERPWD` و در Guzzle با گزینهٔ یک‌خطی `proxy` داده می‌شود. آنچه واقعاً نتیجه را تعیین می‌کند خود کد نیست؛ رعایت robots.txt، انتظار میان درخواست‌ها و تصمیم دربارهٔ اینکه در کدام خطا دوباره تلاش کنید و در کدام بایستید.

## استخراج داده با کپی‌کردن محتوا یکی نیست

موضوع این نوشته بازنشر محتوای دیگران نیست. برداشتن متن یک خبر، یک مقاله یا صفحهٔ یک فیلم از سایتی دیگر و انتشار آن در سایت خودتان نقض حق تکثیر است و اینکه با PHP انجام شده باشد یا با زبانی دیگر هیچ تفاوتی نمی‌کند.

آنچه اینجا شرح می‌دهیم **استخراج دادهٔ ساخت‌یافته** است: قیمت یک محصول، تعداد موجودی، عنوان، سطرهای یک جدول، نام‌های دامنه در یک فهرست. اینها بیشتر وقت‌ها واقعیت‌های منفرد هستند و اثری خلاقانه نمی‌سازند. انتقال فهرست قیمت تأمین‌کنندهٔ خودتان به پنل خودتان، خواندن جدول عمومی یک نهاد یا پیگیری موجودی محصولات خودتان در یک بازارگاه در همین گروه جا می‌گیرد.

در عمل می‌توانید مرز را با سه پرسش بکشید. آنچه برمی‌دارید یک بلوک متن است یا مقدار یک فیلد؟ داده را در کسب‌وکار خودتان به کار می‌برید یا آن را همچون صفحه‌ای منتشر می‌کنید که جای منبع را می‌گیرد؟ شرایط استفاده و فایل robots.txt سایت دربارهٔ این دسترسی چه می‌گویند؟ جنبهٔ حقوقی را در [آیا وب اسکرپینگ قانونی است؟](/fa/blog/is-data-web-scraping-legal) و نحو robots.txt را در [robots.txt چیست؟](/fa/blog/robots-txt) بررسی کرده‌ایم.

یک مرز فنی دیگر هم هست: اگر صفحه بدون ورود به حساب دیده نمی‌شود، اگر شرایط استفاده دسترسی خودکار را صریحاً ممنوع کرده یا اگر داده اطلاعات شخصی دارد، هیچ‌کدام از کدهای این نوشته مناسب نیست. نخست ببینید رابط برنامه‌نویسی رسمی وجود دارد یا نه.

## اسکرپینگ با PHP چگونه کار می‌کند؟

چهار گام هست و این گام‌ها با زبان تغییر نمی‌کنند؛ فقط کتابخانه‌ای که به کار می‌برید عوض می‌شود.

1. **درخواست فرستاده می‌شود.** یک درخواست ⁦HTTP GET⁩ کد HTML صفحه را دانلود می‌کند. در این گام سرایند `User-Agent`، مهلت زمانی، دنبال‌کردن تغییر مسیر و در صورت وجود پروکسی تنظیم می‌شود.
2. **پاسخ اعتبارسنجی می‌شود.** کد وضعیت خوانده می‌شود. آمدن `200` به معنای دریافت داده نیست؛ بررسی کنید عنصری که انتظار دارید واقعاً در صفحه هست یا نه.
3. **کد HTML تجزیه می‌شود.** متن دریافتی به یک ساختار درختی تبدیل و فیلدهای دلخواه با انتخابگر (انتخابگر CSS یا XPath) بیرون کشیده می‌شود.
4. **داده ذخیره می‌شود.** مقدارها به نوع خود تبدیل می‌شوند (قیمت از متن به عدد اعشاری) و در پایگاه داده یا در یک فایل نوشته می‌شوند.

در سمت درخواست دو گزینه دارید (توابع `curl_*` و Guzzle) و در سمت تجزیه هم دو گزینه (`DOMDocument` و Symfony DomCrawler). در ادامه همه را جداگانه می‌بینید.

## چگونه یک صفحه را با cURL دانلود کنیم؟

افزونهٔ cURL در PHP کتابخانهٔ libcurl را، یعنی همان کتابخانهٔ پشت ابزار `curl` خط فرمان، به PHP می‌گشاید. نام گزینه‌ها هم با همان منطق نوشته می‌شود، پس تبدیل درخواستی که در پایانه آزموده‌اید به کد آسان است. برای معادل‌های خط فرمانی این پرچم‌ها به [استفاده از پروکسی با cURL](/fa/blog/curl-proxy) نگاه کنید.

```php
<?php
declare(strict_types=1);

// Downloads a single page with cURL; checks errors, status code and timeouts.
function fetchPage(string $url): string
{
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,   // do not echo the response, return it
        CURLOPT_FOLLOWLOCATION => true,   // follow 301/302 redirects
        CURLOPT_MAXREDIRS      => 5,
        CURLOPT_CONNECTTIMEOUT => 10,     // time to establish the connection (seconds)
        CURLOPT_TIMEOUT        => 30,     // total time for the request (seconds)
        CURLOPT_ENCODING       => '',     // decompress gzip/deflate responses
        CURLOPT_USERAGENT      => 'price-sync/1.0 (+https://example.com/bot)',
    ]);

    $body = curl_exec($ch);
    if ($body === false) {
        // Network error: DNS, connection refused, timeout
        throw new RuntimeException('cURL error ' . curl_errno($ch) . ': ' . curl_error($ch));
    }

    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    if ($status !== 200) {
        throw new RuntimeException("HTTP $status: $url");
    }

    return $body;
}
```

چند گزینه اهمیت ویژه دارند. بدون `CURLOPT_RETURNTRANSFER`، پاسخ را cURL مستقیم چاپ می‌کند و `curl_exec` تنها `true` به شما برمی‌گرداند. وقتی به `CURLOPT_ENCODING` رشتهٔ خالی بدهید، cURL پاسخ فشرده را خودش باز می‌کند؛ اگر از آن بگذرید از برخی سایت‌ها دادهٔ دودویی ناخوانا می‌آید. دو مهلت زمانی جداگانه هم تصادفی نیست: `CURLOPT_CONNECTTIMEOUT` برقراری اتصال و `CURLOPT_TIMEOUT` کل درخواست را محدود می‌کند. فهرست کامل گزینه‌ها در [صفحهٔ `curl_setopt` در php.net](https://www.php.net/manual/en/function.curl-setopt.php) هست.

توجه کنید که دو گونه خطا را جداگانه می‌گیریم. اگر `curl_exec` مقدار `false` برگرداند، اصلاً پاسخی نیامده است؛ این خطای لایهٔ شبکه است. اگر پاسخ آمده باشد و کد `200` نباشد، مشکل سمت سرور است و واکنش به کد بستگی دارد. اینکه در کدام کد باید ایستاد و در کدام دوباره تلاش کرد را در [کدهای وضعیت HTTP در اسکرپینگ](/fa/blog/http-status-codes-web-scraping) در قالب جدول گرد آورده‌ایم.

## چرا file_get_contents و عبارت باقاعده کافی نیستند؟

بیشتر آموزش‌ها با `file_get_contents` آغاز می‌شوند. چون یک خط است وسوسه‌انگیز به نظر می‌رسد اما سه چیز را پنهان می‌کند.

نخست کد وضعیت. وقتی صفحه‌ای ناموجود را گرفتیم، تابع یک هشدار تولید کرد و `false` برگرداند؛ تنها راه دانستن کد، خواندن سطر نخست آرایهٔ `$http_response_header` بود که پس از فراخوانی جادویی پدیدار می‌شود. دوم مهلت زمانی: مقدار پیش‌فرض `default_socket_timeout` شصت ثانیه است، یعنی یک صفحهٔ بی‌پاسخ اسکریپت شما را یک دقیقه معطل می‌کند. سوم تنظیم پروکسی و سرایند: هر دو تنها با نوشتن دستی یک بلوک `stream_context_create` ممکن می‌شوند. همین کار را cURL از پیش انجام می‌دهد.

کلاسیک دوم، تجزیهٔ HTML با عبارت باقاعده است. برای نشان‌دادن اینکه چرا می‌شکند یک نمونه بس است. از دو تگ قیمت زیر یکی شکست سطر دارد و دیگری به جای نقل‌قول دوتایی از نقل‌قول یگانه استفاده کرده است:

```php
$fragment = "<p class=\"price_color\">\n  £51.77\n</p><p class='price_color'>£53.74</p>";

preg_match_all('/<p class="price_color">(.*?)<\/p>/', $fragment, $m);
echo count($m[1]);   // 0

$dom = Dom\HTMLDocument::createFromString('<div>' . $fragment . '</div>', LIBXML_NOERROR);
echo $dom->querySelectorAll('.price_color')->length;   // 2
```

عبارت باقاعده هیچ‌کدام را نیافت، تجزیه‌گر هر دو را یافت. در صفحه‌های واقعی این دو تفاوت استثنا نیستند بلکه قاعده‌اند؛ روی آن ترتیب کلاس‌ها، ویژگی‌های اضافه و تگ‌های تودرتو هم افزوده می‌شود. به جای پیچیده‌ترکردن الگو در هر نوبت، از همان آغاز تجزیه‌گر به کار ببرید.

## تجزیهٔ HTML: DOMDocument، XPath و ⁦PHP 8.4⁩

در هستهٔ PHP دو تجزیه‌گر هست. قدیمی `DOMDocument` است و تازه فضای نام `Dom` که با ⁦PHP 8.4⁩ آمد. [بنا بر صفحهٔ ویژگی‌های تازهٔ ⁦PHP 8.4⁩](https://www.php.net/manual/en/migration84.new-features.php) کلاس‌های تازه با HTML5 سازگارند و از مشخصات WHATWG پیروی می‌کنند؛ کلاس‌های قدیمی برای سازگاری با گذشته باقی مانده‌اند.

در عمل سه تفاوت هست. `DOMDocument::loadHTML` برای خطاهای HTML در صفحه‌های واقعی هشدار تولید می‌کند، پس پیش از فراخوانی به `libxml_use_internal_errors(true)` نیاز دارد؛ کلاس تازه این نوفه را تولید نمی‌کند. دوم پشتیبانی انتخابگر است: `Dom\HTMLDocument` متدهای `querySelector` و `querySelectorAll` را که از مرورگر می‌شناسید می‌آورد.

تفاوت سوم به هر کسی که صفحه‌های دارای حرف غیر ASCII را می‌خواند مربوط می‌شود: رمزگذاری نویسه‌ها. وقتی قطعه‌ای ⁦UTF-8⁩ بدون تگ `<meta charset>` را به تجزیه‌گر قدیمی دادیم، حرف‌های نشانه‌دار خراب شدند و همان قطعه را تجزیه‌گر تازه درست خواند. اگر ناچارید با کلاس قدیمی کار کنید، باید رمزگذاری را صریحاً اعلام کنید:

```php
$fragment = '<p class="price">Price: 1,250 (Türkiye, Izmir, äöüç)</p>';

$old = new DOMDocument();
libxml_use_internal_errors(true);
$old->loadHTML($fragment);
echo $old->getElementsByTagName('p')->item(0)->textContent;
// Price: 1,250 (TÃ¼rkiye, Izmir, Ã¤Ã¶Ã¼Ã§)

$old2 = new DOMDocument();
$old2->loadHTML('<?xml encoding="UTF-8">' . $fragment);   // state the encoding explicitly
echo $old2->getElementsByTagName('p')->item(0)->textContent;
// Price: 1,250 (Türkiye, Izmir, äöüç)

// PHP 8.4: no extra hint needed
$new = Dom\HTMLDocument::createFromString($fragment, LIBXML_NOERROR);
echo $new->querySelector('p.price')->textContent;
// Price: 1,250 (Türkiye, Izmir, äöüç)
```

پیمودن همهٔ کارت‌های محصول در یک صفحهٔ فهرست با XPath هم با همین منطق نوشته می‌شود. حلقهٔ زیر عنوان، قیمت و وضعیت موجودی 20 کارت سایت آزمایشی را بیرون می‌کشد:

```php
$doc = new DOMDocument();
libxml_use_internal_errors(true);
$doc->loadHTML($html);
libxml_clear_errors();

$xpath = new DOMXPath($doc);
$books = [];

foreach ($xpath->query('//article[contains(@class, "product_pod")]') as $card) {
    $books[] = [
        'title' => $xpath->evaluate('string(.//h3/a/@title)', $card),
        'price' => $xpath->evaluate('string(.//p[contains(@class, "price_color")])', $card),
        'stock' => trim($xpath->evaluate('string(.//p[contains(@class, "availability")])', $card)),
    ];
}
```

اینکه `contains(@class, ...)` می‌نویسیم از آن روست که ویژگی `class` بیشتر وقت‌ها بیش از یک نام کلاس دارد؛ برابری `@class="product_pod"` تگ `class="product_pod col-xs-6"` را از دست می‌دهد. و `string(...)` در انتخاب خالی به جای استثنا رشتهٔ خالی می‌دهد. مقایسهٔ این دو زبان انتخابگر را در [انتخابگر CSS و XPath](/fa/blog/css-selector-vs-xpath) می‌یابید.

## کدام لایه را انتخاب کنیم؟

| لایه | برای چه | برتری | کاستی |
|---|---|---|---|
| `file_get_contents` | آزمایش یک‌باره | بدون نصب | کد وضعیت و مهلت زمانی دیده نمی‌شود |
| توابع `curl_*` | یک صفحه، وابستگی کم | در هسته هست، همهٔ گزینه‌ها در دست شما | هر درخواست را دستی می‌چینید |
| Guzzle | کار منظم چندصفحه‌ای | تلاش دوباره، هم‌زمانی، استثناهای تمیز | وابستگی به Composer |
| عبارت باقاعده | هیچ‌کدام | کوتاه به نظر می‌رسد | با تفاوت فاصله و نقل‌قول می‌شکند |
| `DOMDocument` + XPath | تجزیه با هسته | بدون وابستگی، XPath نیرومند است | نشانهٔ رمزگذاری و نوفهٔ `libxml` |
| `Dom\HTMLDocument` | ⁦PHP 8.4⁩ و بالاتر | سازگار با HTML5، دارای `querySelector` | در نسخه‌های قدیمی نیست |
| Symfony DomCrawler | پیمودن فهرست و پیوند | انتخابگر CSS، `each()`، پیوند مطلق | دو بستهٔ دیگر نصب می‌کنید |

## چگونه با cURL از پروکسی استفاده کنیم؟

کاری که مرتب شمار زیادی صفحه می‌کشد، دیر یا زود به مرز وابستگی به یک نشانی خروجی می‌خورد: سایتی که در دقیقه صدها درخواست از یک IP می‌بیند `429` برمی‌گرداند یا بلوک‌های مرکز داده را می‌شناسد و `403` می‌دهد. پروکسی نقطهٔ خروج این درخواست‌ها را عوض می‌کند.

در cURL دو گزینه بس است:

```php
$ch = curl_init('https://example.com/product/123');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_PROXY          => 'pr.proxynet.io:8000',
    CURLOPT_PROXYUSERPWD   => 'user:pass',
    CURLOPT_CONNECTTIMEOUT => 10,
    CURLOPT_TIMEOUT        => 30,
]);
$body = curl_exec($ch);
```

می‌توانید اطلاعات هویتی را داخل نشانی هم بنویسید (`CURLOPT_PROXY => 'http://user:pass@pr.proxynet.io:8000'`). اگر گذرواژه `@`، `:` یا `/` داشته باشد گزینهٔ جداگانه امن‌تر است، چون این نویسه‌ها باید داخل نشانی رمزگذاری شوند. دو روش احراز هویت و جایگزین فهرست سفید IP را در [احراز هویت پروکسی](/fa/blog/proxy-authentication-methods) شرح داده‌ایم.

برای SOCKS5 باید نوع پروکسی را هم بگویید. تمایز کلیدی اینجا این است که نام دامنه را چه کسی حل می‌کند:

```php
// The proxy resolves the domain (socks5h): your DNS query also goes through the proxy
curl_setopt($ch, CURLOPT_PROXY, 'pr.proxynet.io:1080');
curl_setopt($ch, CURLOPT_PROXYTYPE, CURLPROXY_SOCKS5_HOSTNAME);
curl_setopt($ch, CURLOPT_PROXYUSERPWD, 'user:pass');

// The same thing written on one line
curl_setopt($ch, CURLOPT_PROXY, 'socks5h://user:pass@pr.proxynet.io:1080');

// The local machine resolves the domain
curl_setopt($ch, CURLOPT_PROXY, 'socks5://user:pass@pr.proxynet.io:1080');
```

در آزمایش‌های ما دو دام پیدا شد. نخست: اگر در نشانی درگاه ننویسید، libcurl به‌طور پیش‌فرض درگاه 1080 را می‌آزماید، چون [تعریف `CURLOPT_PROXY` در مستندات libcurl](https://curl.se/libcurl/c/CURLOPT_PROXY.html) همین را می‌گوید. خطای «could not connect» که هنگام نوشتن پروکسی HTTP بدون درگاه می‌گیرید از همین‌جاست.

دوم اینکه گذرواژهٔ نادرست به دو شکل متفاوت دیده می‌شود. در راه رفتن به یک نشانی HTTPS پروکسی یک تونل می‌سازد؛ اگر گذرواژه نادرست باشد تونل اصلاً ساخته نمی‌شود و `curl_exec` مقدار `false` برمی‌گرداند. `CURLINFO_RESPONSE_CODE` به شما `0` نشان می‌دهد و `407` واقعی تنها داخل `CURLINFO_HTTP_CONNECTCODE` می‌ماند. وقتی همان درخواست به نشانی HTTP برود پاسخی عادی می‌آید و کد وضعیت آشکارا `407` است. یعنی کدی که گذرواژهٔ پروکسی را بررسی می‌کند باید هر دو میدان را بخواند:

```php
$body    = curl_exec($ch);
$status  = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);       // 0 over HTTPS
$tunnel  = curl_getinfo($ch, CURLINFO_HTTP_CONNECTCODE);    // 407 over HTTPS

if ($status === 407 || $tunnel === 407) {
    throw new RuntimeException('Proxy credentials rejected');
}
```

اینکه کدام نوع پروکسی را برگزینید به هدف بستگی دارد. در ترافیک سنگین به سرور خودتان یا به منبعی بدون محدودیت، [پروکسی دیتاسنتر](https://proxynet.io/fa/datacenter-proxy) ارزان‌ترین راه‌حل است. در سایت‌هایی که نشانی‌های مرکز داده را محدود می‌کنند به [پروکسی مسکونی](https://proxynet.io/fa/residential-proxy) نیاز دارید. وقتی بخواهید نشانی خروجی در هر درخواست عوض شود [پروکسی چرخشی](https://proxynet.io/fa/rotating-proxy) و وقتی لازم است در طول یک نشست روی همان نشانی بمانید [پروکسی با نشست ثابت](https://proxynet.io/fa/sticky-proxy) به کار می‌رود.

## ارسال درخواست و گرفتن خطاها با Guzzle

در کارهای تک‌صفحه‌ای cURL کافی است. اگر کاری می‌نویسید که مرتب ده‌ها صفحه را می‌پیماید، Guzzle سیصد چهارصد سطری را که خودتان می‌نوشتید آماده می‌دهد: میان‌افزار تلاش دوباره، استخر درخواست هم‌زمان و استثناهایی که بر پایهٔ کد وضعیت جدا می‌شوند. تنظیم پروکسی هم به یک خط می‌رسد، چون [بنا بر مستندات گزینه‌های درخواست Guzzle](https://docs.guzzlephp.org/en/stable/request-options.html) گزینهٔ `proxy` یا یک رشتهٔ تنها می‌گیرد یا آرایه‌ای که بر پایهٔ پروتکل جدا شده است.

```php
<?php
require __DIR__ . '/vendor/autoload.php';

use GuzzleHttp\Client;
use GuzzleHttp\Exception\BadResponseException;
use GuzzleHttp\Exception\TransferException;
use Symfony\Component\DomCrawler\Crawler;

$client = new Client([
    'base_uri'        => 'https://example.com/',
    'proxy'           => 'http://user:pass@pr.proxynet.io:8000',
    'connect_timeout' => 10,
    'timeout'         => 30,
    'headers'         => ['User-Agent' => 'price-sync/1.0 (+https://example.com/bot)'],
]);

try {
    $response = $client->get('catalogue/page-1.html');
} catch (BadResponseException $e) {
    // The server returned 4xx or 5xx; the response object is inside the exception
    exit('HTTP ' . $e->getResponse()->getStatusCode() . PHP_EOL);
} catch (TransferException $e) {
    // No response at all: DNS, timeout, proxy tunnel (including 407)
    exit('Network error: ' . $e->getMessage() . PHP_EOL);
}

$crawler = new Crawler((string) $response->getBody(), 'https://example.com/catalogue/page-1.html');

$books = $crawler->filter('article.product_pod')->each(fn (Crawler $card) => [
    'title' => $card->filter('h3 a')->attr('title'),
    'price' => (float) preg_replace('/[^0-9.]/', '', $card->filter('.price_color')->text()),
    'stock' => str_contains($card->filter('.availability')->text(), 'In stock'),
    'url'   => $card->filter('h3 a')->link()->getUri(),
]);
```

دادن نشانی صفحه به شیء `Crawler` همچون پارامتر دوم جزئیاتی کوچک اما کلیدی است: بدون آن `link()->getUri()` نمی‌تواند نشانی نسبی را به نشانی مطلق تبدیل کند و استثنا پرتاب می‌کند. تمام منطق صفحه‌بندی را در [صفحه‌بندی در وب اسکرپینگ](/fa/blog/pagination-web-scraping) بررسی کرده‌ایم. یک هشدار هم دربارهٔ `text()`: چنان‌که [مستندات DomCrawler](https://symfony.com/doc/current/components/dom_crawler.html) می‌گوید، وقتی انتخابگر چیزی نیابد استثنا پرتاب می‌کند؛ برای اینکه یک فیلد نبوده کار را متوقف نکند مقدار پیش‌فرض بدهید (`->text('')`).

کلاس‌های استثنا دو شاخهٔ اصلی دارند و هر دو از `TransferException` پایین می‌آیند:

| استثنا | چه زمانی | شیء پاسخ دارد |
|---|---|---|
| `ClientException` | پاسخ `4xx` (`404`، در مقصد HTTP کد `407`) | دارد |
| `ServerException` | پاسخ `5xx` | دارد |
| `ConnectException` | اتصال برقرار نشد، درگاه بسته، مهلت زمانی | ندارد |
| `TransferException` (کلاس بالادست) | تونل ساخته نشد، در مقصد HTTPS کد `407` | ندارد |

⁦Guzzle 8⁩ برای خطاهای اتصال کلاس‌های ریزتری مانند `NetworkException` و `ConnectTimeoutException` افزود. برای کدی که در هر دو نسخه کار کند، ترتیب گرفتن را مانند بالا بچینید: نخست `BadResponseException` و سپس `TransferException`.

Guzzle به‌طور پیش‌فرض در پاسخ‌های `4xx` و `5xx` استثنا پرتاب می‌کند. در کاری که صدها نشانی را می‌پیماید خواندن کد وضعیت همچون یک مقدار راحت‌تر است: با `'http_errors' => false` درخواستی که `404` می‌گیرد بی‌صدا یک شیء پاسخ با کد `404` برمی‌گرداند.

## robots.txt، انتظار و تلاش دوباره

اجرا شدن کد بس نیست؛ باید درست رفتار کند. سه قاعده هست.

**robots.txt خوانده می‌شود.** استاندارد با [⁦RFC 9309⁩](https://www.rfc-editor.org/rfc/rfc9309.html) تعریف شده و چهار رفتار را آشکارا می‌گوید: بلندترین قاعدهٔ منطبق برنده است، اگر `allow` و `disallow` هم‌ارز باشند `allow` برنده است، اگر فایل `4xx` برگرداند محدودیتی در کار نیست و اگر `5xx` برگرداند هر مسیر ممنوع شمرده می‌شود. دو تابع زیر این چهار قاعده را پیاده می‌کنند و سطرهای پشت سر هم `User-agent` را در یک گروه ادغام می‌کنند:

```php
const BOT_TOKEN = 'price-sync';   // the name in our User-Agent header

// Extracts the Allow/Disallow lines of the group that applies to us from robots.txt.
function loadRobotsRules(Client $client): array
{
    $response = $client->get('/robots.txt');
    $status = $response->getStatusCode();
    if ($status >= 500) {
        return [['disallow', '/']];   // unreachable: every path is disallowed
    }
    if ($status >= 400) {
        return [];                    // no file: no restriction
    }

    $groups = [];
    $agents = [];
    $inRules = false;
    foreach (preg_split('/\R/', (string) $response->getBody()) as $line) {
        $line = trim(preg_replace('/#.*/', '', $line));
        if (!preg_match('/^(user-agent|allow|disallow)\s*:\s*(.*)$/i', $line, $m)) {
            continue;
        }
        [$field, $value] = [strtolower($m[1]), $m[2]];
        if ($field === 'user-agent') {
            if ($inRules) {
                [$agents, $inRules] = [[], false];   // a new group starts here
            }
            $agents[] = strtolower($value);
            continue;
        }
        $inRules = true;
        foreach ($agents as $agent) {
            $groups[$agent][] = [$field, $value];
        }
    }

    return $groups[BOT_TOKEN] ?? $groups['*'] ?? [];
}

// The longest matching rule wins; Allow wins on a tie (RFC 9309).
function isAllowed(string $path, array $rules): bool
{
    [$bestLength, $allowed] = [-1, true];
    foreach ($rules as [$field, $pattern]) {
        if ($pattern === '') {
            continue;   // empty Disallow: no restriction
        }
        $regex = '#^' . str_replace(['\*', '\$'], ['.*', '$'], preg_quote($pattern, '#')) . '#';
        if (!preg_match($regex, $path)) {
            continue;
        }
        $length = strlen($pattern);
        if ($length > $bestLength || ($length === $bestLength && $field === 'allow')) {
            [$bestLength, $allowed] = [$length, $field === 'allow'];
        }
    }
    return $allowed;
}
```

**میان درخواست‌ها انتظار می‌کشیم.** گزینهٔ `delay` در Guzzle پیش از هر درخواست انتظاری برحسب میلی‌ثانیه می‌گذارد. یک ثانیه برای بیشتر کارها آغاز معقولی است و هم‌زمانی پایین نگه داشته می‌شود. قاعده ساده است: آن‌قدر کند باشید که کنار ترافیک عادی بازدیدکنندگان سایت به چشم نیایید.

**به خطاها بر پایهٔ کد واکنش نشان می‌دهیم.** میان‌افزار `Middleware::retry` در Guzzle دو فراخوان بازگشتی می‌گیرد: یکی که تصمیم می‌گیرد در چه حالتی دوباره تلاش شود و یکی که می‌گوید چقدر باید صبر کرد. روی سرور آزمایشی ما نشانی‌ای که دو بار `503` با `Retry-After: 1` برگرداند، در کمتر از دو ثانیه و در تلاش سوم `200` داد؛ نشانی‌ای که `404` برگرداند هرگز دوباره آزموده نشد.

```php
// Retries at most 3 times on temporary errors; never on codes like 403, 404 or 407.
function retryMiddleware(): callable
{
    $decider = function (int $retries, $request, ?ResponseInterface $response = null): bool {
        if ($retries >= 3) {
            return false;
        }
        if ($response === null) {
            return true;   // no response: the connection dropped or timed out
        }
        return in_array($response->getStatusCode(), [408, 429, 500, 502, 503, 504], true);
    };

    $delay = function (int $retries, ?ResponseInterface $response = null): int {
        $retryAfter = $response?->getHeaderLine('Retry-After') ?? '';
        if (ctype_digit($retryAfter)) {
            return min((int) $retryAfter, 60) * 1000;   // the delay the server asked for
        }
        return (2 ** $retries) * 1000 + random_int(0, 500);
    };

    return Middleware::retry($decider, $delay);
}
```

مؤلفهٔ تصادفی افزوده به انتظار اتفاقی نیست: جلوی این را می‌گیرد که درخواست‌هایی که هم‌زمان شکست خورده‌اند هم‌زمان دوباره آغاز شوند و انباشت تازه‌ای بسازند. `403`، `404` و `407` در فهرست نیستند، چون این کدها با انتظار تغییر نمی‌کنند؛ در آنها کار باید بایستد و دلیل در گزارش ثبت شود.

## نمونهٔ کامل: همگام‌سازی قیمت و موجودی

بیایید قطعه‌ها را کنار هم بگذاریم. جریان زیر robots.txt را می‌خواند، صفحه‌های فهرست را می‌پیماید و نشانی محصول‌ها را گرد می‌آورد (`collectProductUrls`، حلقه‌ای ساده که پیوند «صفحهٔ بعد» را دنبال می‌کند)، صفحه‌های محصول را دوتا دوتا می‌کشد، قیمت و موجودی را از جدول بیرون می‌آورد و در SQLite می‌نویسد. وقتی از مسیر پروکسی آزمایشی محلی اجرا کردیم، هر 40 محصول از 40 را در حدود 32 ثانیه ذخیره کرد.

```php
$db = new PDO('sqlite:' . __DIR__ . '/prices.sqlite');
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$db->exec('CREATE TABLE IF NOT EXISTS products (
    upc TEXT PRIMARY KEY, title TEXT, price REAL, stock INTEGER, url TEXT, checked_at TEXT
)');
// When the same product arrives a second time, no row is added; price and stock are updated
$save = $db->prepare('INSERT INTO products (upc, title, price, stock, url, checked_at)
    VALUES (:upc, :title, :price, :stock, :url, :checked_at)
    ON CONFLICT(upc) DO UPDATE SET
        price = excluded.price, stock = excluded.stock, checked_at = excluded.checked_at');

$stack = HandlerStack::create();
$stack->push(retryMiddleware());

$client = new Client([
    'handler'         => $stack,
    'base_uri'        => 'https://example.com/',
    'proxy'           => getenv('PROXY_URL') ?: null,   // http://user:pass@pr.proxynet.io:8000
    'connect_timeout' => 10,
    'timeout'         => 30,
    'http_errors'     => false,   // we read the code as a value instead of an exception
    'headers'         => ['User-Agent' => 'price-sync/1.0 (+https://example.com/bot)'],
]);

$rules = loadRobotsRules($client);
$urls  = array_values(array_filter(
    collectProductUrls($client, $rules),
    fn (string $url) => isAllowed(parse_url($url, PHP_URL_PATH), $rules)
));

$requests = function () use ($urls) {
    foreach ($urls as $url) {
        yield new Request('GET', $url);
    }
};

$saved = 0;
$pool = new Pool($client, $requests(), [
    'concurrency' => 2,                       // number of requests open at once
    'options'     => ['delay' => 1000],       // wait before every request
    'fulfilled'   => function (ResponseInterface $response, int $index) use ($urls, $save, &$saved) {
        if ($response->getStatusCode() !== 200) {
            fwrite(STDERR, "HTTP {$response->getStatusCode()}: {$urls[$index]}\n");
            return;
        }
        $product = parseProduct((string) $response->getBody(), $urls[$index]);
        if ($product === null) {
            fwrite(STDERR, "Unexpected page: {$urls[$index]}\n");
            return;
        }
        $save->execute($product);
        $saved++;
    },
    'rejected'    => function (Throwable $reason, int $index) use ($urls) {
        fwrite(STDERR, "Failed: {$urls[$index]} ({$reason->getMessage()})\n");
    },
]);
$pool->promise()->wait();

printf("%d of %d products saved\n", $saved, count($urls));
```

تابع `parseProduct` که صفحهٔ محصول را می‌خواند با بررسی کوچکی آغاز می‌شود تا پاسخ `200` را کورکورانه نپذیرد: اگر عنصر عنوان مورد انتظار نباشد `null` برمی‌گرداند و جریان اصلی آن نشانی را همچون خطا ثبت می‌کند.

```php
function parseProduct(string $html, string $url): ?array
{
    $crawler = new Crawler($html, $url);
    if ($crawler->filter('.product_main h1')->count() === 0) {
        return null;   // 200 arrived but the expected element is missing
    }

    // Turn the <table> rows into a "heading => value" array
    $table = [];
    $crawler->filter('table.table-striped tr')->each(function (Crawler $row) use (&$table) {
        $table[$row->filter('th')->text()] = $row->filter('td')->text();
    });

    preg_match('/\((\d+) available\)/', $table['Availability'] ?? '', $stock);

    return [
        'upc'        => $table['UPC'],
        'title'      => $crawler->filter('.product_main h1')->text(),
        'price'      => (float) preg_replace('/[^0-9.]/', '', $table['Price (excl. tax)']),
        'stock'      => (int) ($stock[1] ?? 0),
        'url'        => $url,
        'checked_at' => date('c'),
    ];
}
```

دو نکته برای cron: اسکریپت را نه از راه وب‌سرور بلکه از خط فرمان اجرا کنید (`php /path/sync.php`)، چون محدودیت پیش‌فرض زمان اجرا در سمت وب کاری طولانی را از میان می‌برد. اطلاعات هویتی پروکسی را هم به جای کد در متغیر محیطی بگذارید؛ جزئیاتش در [استفاده از پروکسی با wget](/fa/blog/wget-proxy) هست.

## مرز PHP در صفحه‌ای که با JavaScript بارگذاری می‌شود

هر آنچه تا اینجا گفتیم بر یک فرض تکیه دارد: دادهٔ دلخواه شما داخل نخستین HTML است که سرور می‌فرستد. در بخش مهمی از سایت‌های امروزی این درست نیست. سرور اسکلتی خالی می‌فرستد و فهرست محصول را JavaScript در مرورگر بعداً می‌آورد. وقتی این صفحه را با cURL بکشید انتخابگرهای شما چیزی نمی‌یابند، چون تگ‌هایی که می‌جویید هرگز در HTML نوشته نشده‌اند.

اینجا مرز PHP است و ربطی به انتخاب کتابخانه ندارد. Guzzle و DomCrawler هر دو متن دریافتی را تجزیه می‌کنند و هیچ‌کدام JavaScript اجرا نمی‌کند. اگر بخواهید از PHP مرورگر برانید باید با بسته‌ای مانند Panther مرورگر Chrome را از بیرون اجرا کنید، یعنی کار دیگر در PHP نیست بلکه در مرورگر است.

خبر خوب این است: در بیشتر حالت‌ها اصلاً به مرورگر نیازی نیست. درخواست JSON را که صفحه در پس‌زمینه می‌زند می‌توانید در برگهٔ شبکه در ابزارهای توسعه‌دهنده بیابید و همان نشانی را مستقیم با Guzzle فرا بخوانید؛ چون نتیجه از پیش دادهٔ ساخت‌یافته است، گام تجزیه هم برداشته می‌شود. اینکه چگونه بفهمید صفحه‌ای پویاست را گام به گام در [صفحه‌های ایستا و پویا](/fa/blog/static-vs-dynamic-pages) نشان داده‌ایم.

## کاربردها

- **کشیدن قیمت تأمین‌کننده به پنل خودتان:** یک کار cron که شب اجرا می‌شود فهرست محصول را می‌پیماید و میدان‌های قیمت و موجودی را به‌روز می‌کند؛ چیدمانش در صفحهٔ [استخراج داده](/fa/data-scraping) ما هست.
- **پیگیری قیمت رقیبان:** ثبت روزانهٔ قیمت یک محصول در چند سایت؛ در [پیگیری قیمت رقیبان](/fa/blog/competitor-price-tracking) و در صفحهٔ [پایش قیمت](/fa/price-monitoring) ما آمده است.
- **خزیدن در سایت خودتان:** پیمودن دامنهٔ خودتان برای یافتن پیوند شکسته و عنوان جاافتاده؛ در صفحهٔ [خزندهٔ وب](/fa/web-crawler) ما.
- **بررسی آگهی‌های خودتان در بازارگاه:** کنترل اینکه میدان‌های موجودی و عنوان با پنل شما می‌خوانند یا نه؛ در صفحهٔ [راهکارهای تجارت الکترونیک](/fa/e-commerce-proxy) ما.
- **خواندن جدول‌های عمومی:** برداشتن جدول نرخ ارز، تعرفه یا آگهی از سایت‌های نهادی؛ خلاصهٔ فرازبانی این روش در [استخراج داده از یک وب‌سایت](/fa/blog/extract-data-from-website) هست.

## اشتباهات رایج

- **پذیرفتن پاسخ `200` بدون نگاه به محتوا.** صفحه‌های تأیید و خطا هم `200` برمی‌گردانند؛ پیش از هر تجزیه بودن عنصری را که انتظار دارید بررسی کنید.
- **نگذاشتن مهلت زمانی.** یک صفحهٔ بی‌پاسخ به سبب `default_socket_timeout` اسکریپت را یک دقیقه معطل می‌کند. هر دو مهلت را صریح بدهید.
- **ذخیرهٔ قیمت همچون متن.** اگر رشتهٔ `£51.77` را همان‌طور نگه دارید نمی‌توانید مقایسه و جمع کنید. به عدد تبدیل کنید و واحد پول را در ستونی جدا بگذارید.
- **جستن نام کلاس با برابری.** XPath ای که `@class="product_pod"` می‌نویسد تگ `class="product_pod col-xs-6"` را نمی‌یابد.
- **ننوشتن درگاه در نشانی پروکسی.** libcurl به‌طور پیش‌فرض 1080 را می‌آزماید و پیام خطا شما را به بیراهه می‌برد.
- **جستن خطای `407` در سایت مقصد.** این کد از پروکسی می‌آید؛ نام کاربری، گذرواژه و فهرست سفید را بررسی کنید.
- **تنظیم آزمندانهٔ هم‌زمانی.** بیست درخواست موازی کار را تند نمی‌کند، شما را به مرز نرخ می‌کوبد.
- **جاسازی اطلاعات هویتی در کد.** نام کاربری و گذرواژهٔ پروکسی نباید وارد کنترل نسخه شود.

## راهنمای انتخاب

| نیاز | پیشنهاد |
|---|---|
| چند میدان از یک صفحهٔ تنها | `curl_*` + `DOMDocument` و XPath |
| ⁦PHP 8.4⁩ و عادت به انتخابگر CSS | `querySelectorAll` با `Dom\HTMLDocument` |
| کار منظم روی ده‌ها صفحه | Guzzle + DomCrawler با میان‌افزار تلاش دوباره |
| گشتن میان صفحه‌های فهرست | `link()->getUri()` در DomCrawler برای نشانی مطلق |
| درخواست زیاد از یک IP و گرفتن `429` | سرعت را کم کنید، سپس [پروکسی چرخشی](https://proxynet.io/fa/rotating-proxy) |
| نشانی‌های مرکز داده محدود می‌شوند | [پروکسی مسکونی](https://proxynet.io/fa/residential-proxy) |
| در طول نشست به همان نشانی نیاز دارید | [پروکسی با نشست ثابت](https://proxynet.io/fa/sticky-proxy) |
| محتوا با JavaScript می‌آید | نخست دنبال درخواست JSON پس‌زمینه بگردید |
| سایت رابط برنامه‌نویسی رسمی دارد | به جای اسکرپینگ از رابط رسمی استفاده کنید |

## پرسش‌های متداول

### آیا PHP زبان مناسبی برای وب اسکرپینگ است؟

بله، به شرط دانستن مرزش. در سمت درخواست HTTP و تجزیهٔ HTML ابزارها پخته‌اند: افزونهٔ cURL تمام libcurl را می‌گشاید، Guzzle هم‌زمانی و تلاش دوباره می‌دهد و DomCrawler همراه XPath انتخابگرهای نیرومند در اختیار می‌گذارد. جایی که ضعیف است خودکارسازی مرورگر است. اگر قرار است داده را به سامانه‌ای بریزید که از پیش با PHP نوشته شده، نگه‌داشتن کار در PHP ساده‌تر از آوردن داده از زبانی دوم است.

### آیا کتابخانهٔ Simple HTML DOM را به کار ببرم؟

این کتابخانه که در بسیاری از آموزش‌های قدیمی دیده می‌شود مدت‌هاست نگهداری نمی‌شود و در صفحه‌های بزرگ آشکارا کند کار می‌کند. همان کار را `DOMDocument` در هستهٔ PHP بدون وابستگی انجام می‌دهد، در ⁦PHP 8.4⁩ با `Dom\HTMLDocument` سازگار با HTML5 می‌شود و اگر رابطی شبیه jQuery بخواهید Symfony DomCrawler هست. برای پروژه‌ای تازه یکی از این سه را برگزینید.

### تفاوت cURL و Guzzle چیست؟

Guzzle از پیش در پشت صحنه cURL را به کار می‌گیرد؛ تفاوت در سطح انتزاع است. اگر یک صفحهٔ تنها می‌کشید توابع `curl_*` بس است و نیازی به نصب بسته ندارید. اگر تلاش دوباره، استخر درخواست، میان‌افزار و استثناهای جدا بر پایهٔ کد وضعیت می‌خواهید Guzzle را به کار ببرید. تنظیم پروکسی در هر دو چند سطر است.

### هنگام استفاده از پروکسی خطای 407 می‌گیرم، چه کنم؟

`407` از سایت مقصد نمی‌آید بلکه از پروکسی می‌آید و می‌گوید احراز هویت ناکام مانده است. نخست نام کاربری و گذرواژه را بررسی کنید. اگر گذرواژه `@` یا `:` دارد باید داخل نشانی رمزگذاری شده باشد؛ استفادهٔ جداگانه از گزینهٔ `CURLOPT_PROXYUSERPWD` این مشکل را برمی‌دارد. اگر روش فهرست سفید IP را به کار می‌برید، مطمئن شوید نشانی خروجی سرور شما در فهرست هست. و فراموش نکنید که در درخواست‌های HTTPS کد `407` به جای `CURLINFO_RESPONSE_CODE` در `CURLINFO_HTTP_CONNECTCODE` دیده می‌شود.

### در صفحه‌ای که کشیده‌ام حرف‌های نشانه‌دار خراب می‌آیند، چرا؟

به احتمال زیاد `DOMDocument::loadHTML` را به کار می‌برید و صفحه تگ `<meta charset>` ندارد. در این حالت تجزیه‌گر محتوا را ⁦UTF-8⁩ نمی‌شمارد. راه‌حل یا افزودن `<?xml encoding="UTF-8">` به ابتدای HTML است یا رفتن به متد `Dom\HTMLDocument::createFromString` در ⁦PHP 8.4⁩ که رمزگذاری را خودش درست تشخیص می‌دهد. فشرده‌آمدن پاسخ هم می‌تواند خرابی مشابهی بسازد؛ برای آن به `CURLOPT_ENCODING` رشتهٔ خالی بدهید.

### هر چند ثانیه یک درخواست بفرستم؟

عدد ثابتی نیست، اما دو سنجه به کار می‌آید: بزرگی هدف (سایت کوچک یک نهاد و بازارگاهی بزرگ بار یکسانی را برنمی‌دارند) و واکنش خود سایت (اگر `429` گرفتن آغاز شده، سرعت شما زیاد است و باید به مقدار `Retry-After` پایبند بمانید). آغاز عملی این است: یک ثانیه میان درخواست‌ها صبر کنید و هم‌زمانی را به دو محدود کنید.

## خلاصه

کشیدن داده با PHP یعنی فرستادن درخواست با `curl_*` و تجزیهٔ HTML دریافتی با `DOMDocument` + XPath یا با DomCrawler. `file_get_contents` و عبارت باقاعده چون کد وضعیت و گوناگونی تگ‌ها را نمی‌بینند در نخستین صفحهٔ واقعی می‌شکنند. وقتی کار از چند صفحه فراتر رفت، میان‌افزار تلاش دوبارهٔ Guzzle و استخر درخواست وارد میدان می‌شوند. در سمت پروکسی `CURLOPT_PROXY` و `CURLOPT_PROXYUSERPWD` بس است؛ فراموش نکنید درگاه را بنویسید و خطای `407` را در سمت پروکسی بجویید. آنچه واقعاً تعیین‌کننده است سه تصمیم پیش از کد است: رعایت robots.txt، انتظار میان درخواست‌ها و کشیدن تنها دادهٔ واقعیت‌گونه. گونه‌های مناسب پروکسی را در [خدمات پروکسی](/fa/proxy) ما می‌یابید.
