ProxynetProxynet

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

تاریخ انتشار:

18 دقیقه مطالعه

Acar Diveroli
نویسنده: Acar Diveroli
کادری که با ⁨XPath⁩ از پنجره فهرست کالا برگزیده شده به کارت آبی «استخراج شد» می‌رود؛ چپ مکعب خروج پروکسی

فهرست قیمت یک تأمین‌کننده هفته‌ای دو بار تغییر می‌کند و شما آن فهرست را دستی در پنل خودتان وارد می‌کنید. سایت نه رابط برنامه‌نویسی دارد و نه فایلی برای دانلود؛ داده فقط داخل صفحهٔ 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 انجام شده باشد یا با زبانی دیگر هیچ تفاوتی نمی‌کند.

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

در عمل می‌توانید مرز را با سه پرسش بکشید. آنچه برمی‌دارید یک بلوک متن است یا مقدار یک فیلد؟ داده را در کسب‌وکار خودتان به کار می‌برید یا آن را همچون صفحه‌ای منتشر می‌کنید که جای منبع را می‌گیرد؟ شرایط استفاده و فایل robots.txt سایت دربارهٔ این دسترسی چه می‌گویند؟ جنبهٔ حقوقی را در آیا وب اسکرپینگ قانونی است؟ و نحو robots.txt را در 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 نگاه کنید.

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 هست.

توجه کنید که دو گونه خطا را جداگانه می‌گیریم. اگر curl_exec مقدار false برگرداند، اصلاً پاسخی نیامده است؛ این خطای لایهٔ شبکه است. اگر پاسخ آمده باشد و کد 200 نباشد، مشکل سمت سرور است و واکنش به کد بستگی دارد. اینکه در کدام کد باید ایستاد و در کدام دوباره تلاش کرد را در کدهای وضعیت HTTP در اسکرپینگ در قالب جدول گرد آورده‌ایم.

چرا 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⁩ کلاس‌های تازه با 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 می‌یابید.

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

لایهبرای چهبرتریکاستی
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 را در احراز هویت پروکسی شرح داده‌ایم.

برای 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 همین را می‌گوید. خطای «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');
}

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

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

در کارهای تک‌صفحه‌ای cURL کافی است. اگر کاری می‌نویسید که مرتب ده‌ها صفحه را می‌پیماید، Guzzle سیصد چهارصد سطری را که خودتان می‌نوشتید آماده می‌دهد: میان‌افزار تلاش دوباره، استخر درخواست هم‌زمان و استثناهایی که بر پایهٔ کد وضعیت جدا می‌شوند. تنظیم پروکسی هم به یک خط می‌رسد، چون بنا بر مستندات گزینه‌های درخواست Guzzle گزینهٔ 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() نمی‌تواند نشانی نسبی را به نشانی مطلق تبدیل کند و استثنا پرتاب می‌کند. تمام منطق صفحه‌بندی را در صفحه‌بندی در وب اسکرپینگ بررسی کرده‌ایم. یک هشدار هم دربارهٔ text(): چنان‌که مستندات DomCrawler می‌گوید، وقتی انتخابگر چیزی نیابد استثنا پرتاب می‌کند؛ برای اینکه یک فیلد نبوده کار را متوقف نکند مقدار پیش‌فرض بدهید (->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⁩ تعریف شده و چهار رفتار را آشکارا می‌گوید: بلندترین قاعدهٔ منطبق برنده است، اگر 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 هست.

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

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

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

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

کاربردها

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

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

  • پذیرفتن پاسخ 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⁩ و عادت به انتخابگر CSSquerySelectorAll با Dom\HTMLDocument
کار منظم روی ده‌ها صفحهGuzzle + DomCrawler با میان‌افزار تلاش دوباره
گشتن میان صفحه‌های فهرستlink()->getUri() در DomCrawler برای نشانی مطلق
درخواست زیاد از یک IP و گرفتن 429سرعت را کم کنید، سپس پروکسی چرخشی
نشانی‌های مرکز داده محدود می‌شوندپروکسی مسکونی
در طول نشست به همان نشانی نیاز داریدپروکسی با نشست ثابت
محتوا با 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، انتظار میان درخواست‌ها و کشیدن تنها دادهٔ واقعیت‌گونه. گونه‌های مناسب پروکسی را در خدمات پروکسی ما می‌یابید.

پرسش از ChatGPTپرسش از Claude