PHP ile Siteden Veri Çekme: cURL, Guzzle ve Proxy Kullanımı

Yayın tarihi:

15 dk okuma

Acar Diveroli
Yazar: Acar Diveroli
Ürün listesi penceresinden XPath ile seçilen kutu, önündeki mavi çıkarıldı kartına taşınıyor; solda proxy çıkış küpü duruyor

Bir tedarikçinin fiyat listesi haftada iki kez değişiyor ve siz o listeyi kendi panelinize elle giriyorsunuz. Sitede ne bir API var ne de indirilebilir bir dosya; veri yalnızca HTML sayfasının içinde duruyor. Projeniz PHP ile yazıldığı için çözümü de PHP tarafında arıyorsunuz: aynı sunucuda çalışsın, aynı veritabanına yazsın, gece bir kez cron ile koşsun.

Bu yazıda PHP'nin kendi araçlarıyla bir sayfadan yapılandırılmış veri çıkarmayı anlatıyoruz. Sırasıyla cURL ile isteği göndermeyi, DOMDocument ve XPath ile HTML'i ayrıştırmayı, PHP 8.4'ün yeni HTML5 ayrıştırıcısını, Guzzle ile hata yönetimi ve eşzamanlı istekleri, proxy ayarını (CURLOPT_PROXY, SOCKS5 ve Guzzle'ın proxy seçeneği), robots.txt okumayı, bekleme ve yeniden denemeyi ele alıyoruz. Sonda veriyi PDO ile veritabanına yazan tam bir örnek var. Bütün kodları PHP 8.4 ile books.toscrape.com üzerinde ve yerel bir test proxy'siyle çalıştırdık.

Veri çıkarımı ile içerik kopyalama aynı şey değil

Türkçe kaynaklarda "PHP bot yapımı" başlığıyla yıllardır dolaşan örneklerin önemli bir kısmı aslında tek bir şeyi öğretiyor: başka bir sitenin haber metnini, film sayfasını ya da makalesini olduğu gibi çekip kendi sitesinde yayımlamak. Bu yazının konusu o değil. Başkasının yazdığı metni izinsiz kopyalayıp yayımlamak telif hakkı ihlalidir ve bunun PHP ile mi başka bir dille mi yapıldığının hiçbir önemi yoktur.

Burada anlattığımız şey yapılandırılmış veri çıkarımı: bir ürünün fiyatı, stok adedi, başlığı, bir tablodaki satırlar, bir listedeki alan adları. Bunlar çoğu zaman tek tek olgu niteliğindedir ve yaratıcı bir eser oluşturmazlar. Kendi tedarikçinizin fiyat listesini kendi panelinize taşımak, kamuya açık bir kurum tablosunu okumak ya da kendi ürünlerinizin pazaryerindeki stok durumunu izlemek bu gruba girer.

Aradaki sınırı pratikte üç soruyla çizebilirsiniz. Çektiğiniz şey bir metin bloğu mu yoksa bir alan değeri mi? Veriyi kendi işinizde mi kullanıyorsunuz, yoksa kaynağın yerine geçecek bir sayfa olarak mı yayımlıyorsunuz? Sitenin kullanım şartları ve robots.txt dosyası bu erişime ne diyor? Hukuki tarafı Web Scraping Yasal mı? yazımızda, robots.txt söz dizimini robots.txt Nedir? yazımızda ele aldık.

Teknik bir sınır daha var: sayfa giriş yapılmadan görünmüyorsa, şartlar otomatik erişimi açıkça yasaklıyorsa ya da veri kişisel bilgi içeriyorsa bu yazıdaki hiçbir kod uygun değildir. Önce resmi bir API olup olmadığına bakın.

PHP ile veri çekme nasıl çalışır?

Dört adım var ve bunlar dile göre değişmez, yalnızca kullandığınız kütüphane değişir.

  1. İstek gönderilir. Bir HTTP GET isteğiyle sayfanın HTML'i indirilir. Bu adımda User-Agent başlığı, zaman aşımı, yönlendirme takibi ve varsa proxy ayarlanır.
  2. Yanıt doğrulanır. Durum kodu okunur. 200 gelmesi veriyi aldığınız anlamına gelmez; sayfada beklediğiniz öğenin gerçekten var olduğu kontrol edilir.
  3. HTML ayrıştırılır. Gelen metin bir ağaç yapısına dönüştürülür ve istediğiniz alanlar seçicilerle (CSS selector ya da XPath) çıkarılır.
  4. Veri kaydedilir. Çıkan değerler tipine çevrilir (fiyat metinden ondalık sayıya), veritabanına ya da dosyaya yazılır.

İstek tarafında iki seçeneğiniz var (curl_* fonksiyonları ve Guzzle), ayrıştırma tarafında iki (DOMDocument ve Symfony DomCrawler). Aşağıda hepsini ayrı ayrı görüyorsunuz.

cURL ile sayfa nasıl indirilir?

PHP'nin cURL uzantısı, komut satırındaki curl aracının arkasındaki libcurl kütüphanesini PHP'ye açar. Seçenek adları da aynı mantıkla yazılır, bu yüzden terminalde denediğiniz bir isteği koda çevirmek kolaydır. Bayrakların komut satırı karşılıkları için cURL ile Proxy Nasıl Kullanılır? yazımıza bakabilirsiniz.

php
<?php
declare(strict_types=1);

// Tek bir sayfayı cURL ile indirir; hata, durum kodu ve zaman aşımı kontrolü yapar.
function fetchPage(string $url): string
{
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,   // yanıtı ekrana basma, değişkene döndür
        CURLOPT_FOLLOWLOCATION => true,   // 301/302 yönlendirmelerini izle
        CURLOPT_MAXREDIRS      => 5,
        CURLOPT_CONNECTTIMEOUT => 10,     // bağlantı kurma süresi (saniye)
        CURLOPT_TIMEOUT        => 30,     // isteğin toplam süresi (saniye)
        CURLOPT_ENCODING       => '',     // gzip/deflate yanıtını kendisi açar
        CURLOPT_USERAGENT      => 'price-sync/1.0 (+https://example.com/bot)',
    ]);

    $body = curl_exec($ch);
    if ($body === false) {
        // Ağ hatası: DNS, bağlantı reddi, zaman aşımı
        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;
}

Birkaç seçenek özellikle önemli. CURLOPT_RETURNTRANSFER olmazsa cURL yanıtı doğrudan çıktıya basar ve curl_exec size yalnızca true döner. CURLOPT_ENCODING boş dizgeyle verildiğinde cURL sıkıştırılmış yanıtı kendisi açar; bunu atlarsanız bazı sitelerden okunamayan ikili veri gelir. Zaman aşımının iki ayrı olması da rastlantı değil: CURLOPT_CONNECTTIMEOUT bağlantının kurulmasını, CURLOPT_TIMEOUT isteğin tamamını sınırlar. Seçeneklerin tam listesi php.net'teki curl_setopt sayfasında.

İki hata türünü ayrı ayrı yakaladığımıza dikkat edin. curl_exec false dönerse yanıt hiç gelmemiştir; bu ağ katmanının hatasıdır. Yanıt geldiyse ve kod 200 değilse sorun sunucu tarafındadır ve tepki koda göre değişir. Hangi kodda durup hangisinde yeniden denemek gerektiğini Scraping'de HTTP Hata Kodları yazımızda tablo halinde topladık.

file_get_contents ve düzenli ifade neden yetmez?

Türkçe örneklerin çoğu file_get_contents ile başlar. Tek satır olduğu için cazip görünür ama üç şeyi görünmez kılar.

Birincisi durum kodu. Var olmayan bir sayfayı çektiğimizde fonksiyon bir uyarı üretip false döndü; kodu öğrenmenin tek yolu, çağrıdan sonra sihirli biçimde ortaya çıkan $http_response_header dizisinin ilk satırını okumaktı. İkincisi zaman aşımı: varsayılan default_socket_timeout değeri 60 saniyedir, yani cevap vermeyen tek bir sayfa betiğinizi bir dakika bekletir. Üçüncüsü proxy ve başlık ayarı: ikisi de ancak elle bir stream_context_create bloğu yazarsanız mümkün olur. Aynı işi cURL zaten yapıyor.

İkinci klasik, HTML'i düzenli ifadeyle ayrıştırmak. Bunun neden kırıldığını göstermek için tek bir örnek yeter. Aşağıdaki iki fiyat etiketinden biri satır sonu içeriyor, diğeri tırnak yerine kesme işareti kullanıyor:

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

Düzenli ifade hiçbirini bulamadı, ayrıştırıcı ikisini de buldu. Gerçek sayfalarda bu iki fark istisna değil kuraldır; üstüne sınıf sırası, fazladan öznitelik ve iç içe etiket eklenir. Kalıbı her seferinde karmaşıklaştırmak yerine baştan ayrıştırıcı kullanın.

HTML nasıl ayrıştırılır: DOMDocument, XPath ve PHP 8.4

PHP'nin çekirdeğinde iki ayrıştırıcı var. Eskisi DOMDocument, yenisi PHP 8.4 ile gelen Dom ad alanı. PHP 8.4 yenilikleri sayfasına göre yeni sınıflar HTML5 ile uyumlu ve WHATWG belirtimine uyuyor; eski sınıflar geriye dönük uyumluluk için duruyor.

Pratikte üç fark var. DOMDocument::loadHTML gerçek sayfalardaki HTML hataları için uyarı üretir, bu yüzden çağrıdan önce libxml_use_internal_errors(true) gerekir; yeni sınıf bu gürültüyü çıkarmaz. İkincisi seçici desteği: Dom\HTMLDocument tarayıcıdan bildiğiniz querySelector ve querySelectorAll metotlarını getirir.

Üçüncü fark Türkçe içerik çeken herkesi doğrudan ilgilendiriyor: karakter kodlaması. <meta charset> etiketi olmayan UTF-8 bir parçayı eski ayrıştırıcıya verdiğimizde Türkçe harfler bozuldu, aynı parçayı yeni ayrıştırıcı doğru okudu. Eski sınıfla çalışmak zorundaysanız kodlamayı açıkça bildirmeniz gerekir:

php
$fragment = '<p class="price">Fiyat: 1.250 lira (Türkiye, İstanbul, şğüöç)</p>';

$old = new DOMDocument();
libxml_use_internal_errors(true);
$old->loadHTML($fragment);
echo $old->getElementsByTagName('p')->item(0)->textContent;
// Fiyat: 1.250 lira (Türkiye, İstanbul, ÅÄüöç)

$old2 = new DOMDocument();
$old2->loadHTML('<?xml encoding="UTF-8">' . $fragment);   // kodlamayı açıkça söyle
echo $old2->getElementsByTagName('p')->item(0)->textContent;
// Fiyat: 1.250 lira (Türkiye, İstanbul, şğüöç)

// PHP 8.4: ek bir ipucu gerekmiyor
$new = Dom\HTMLDocument::createFromString($fragment, LIBXML_NOERROR);
echo $new->querySelector('p.price')->textContent;
// Fiyat: 1.250 lira (Türkiye, İstanbul, şğüöç)

Bir liste sayfasındaki bütün ürün kartlarını XPath ile dolaşmak da aynı mantıkla yazılır. Aşağıdaki döngü, test sitesindeki 20 kartın başlığını, fiyatını ve stok durumunu çıkarıyor:

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, ...) yazmamızın nedeni, class özniteliğinin çoğu zaman birden fazla sınıf adı taşımasıdır; @class="product_pod" eşitliği class="product_pod col-xs-6" etiketini kaçırır. string(...) ise boş seçimde istisna yerine boş dizge verir. İki seçici dilinin karşılaştırmasını CSS Selector ve XPath yazımızda bulabilirsiniz.

Hangi katmanı seçmeli?

KatmanNe içinArtısıEksisi
file_get_contentsTek seferlik denemeSıfır kurulumDurum kodu ve zaman aşımı görünmez
curl_* fonksiyonlarıTek sayfa, az bağımlılıkÇekirdekte var, her seçenek elinizdeHer isteği elle kurarsınız
GuzzleÇok sayfalı düzenli işYeniden deneme, eşzamanlılık, temiz istisnalarComposer bağımlılığı
Düzenli ifadeHiçbiriKısa görünürBoşluk ve tırnak farkında kırılır
DOMDocument + XPathÇekirdekle ayrıştırmaBağımlılık yok, XPath güçlüKodlama ipucu ve libxml gürültüsü
Dom\HTMLDocumentPHP 8.4 ve üstüHTML5 uyumlu, querySelector varEski sürümlerde yok
Symfony DomCrawlerListe ve bağlantı gezmeCSS seçici, each(), mutlak bağlantıİki paket daha kurarsınız

cURL ile proxy nasıl kullanılır?

Çok sayıda sayfayı düzenli olarak çeken bir iş, er ya da geç tek bir çıkış adresine bağlı olmanın sınırına çarpar: aynı IP'den dakikada yüzlerce istek gören site 429 döner ya da veri merkezi bloklarını tanıyıp 403 verir. Proxy, bu isteklerin çıkış noktasını değiştirir.

cURL'de iki seçenek yeter:

php
$ch = curl_init('https://example.com/urun/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);

Kimlik bilgisini adresin içine de yazabilirsiniz (CURLOPT_PROXY => 'http://user:pass@pr.proxynet.io:8000'). Şifrede @, : ya da / varsa ayrı seçenek daha güvenlidir, çünkü adres içinde bu karakterlerin kodlanması gerekir. İki kimlik doğrulama yöntemini ve IP whitelist alternatifini Proxy Kimlik Doğrulama yazımızda anlattık.

SOCKS5 için proxy türünü de söylemeniz gerekir. Buradaki kritik ayrım, alan adını kimin çözdüğüdür:

php
// Alan adını proxy çözer (socks5h): DNS isteğiniz de proxy üzerinden gider
curl_setopt($ch, CURLOPT_PROXY, 'pr.proxynet.io:1080');
curl_setopt($ch, CURLOPT_PROXYTYPE, CURLPROXY_SOCKS5_HOSTNAME);
curl_setopt($ch, CURLOPT_PROXYUSERPWD, 'user:pass');

// Aynısının tek satırlık yazımı
curl_setopt($ch, CURLOPT_PROXY, 'socks5h://user:pass@pr.proxynet.io:1080');

// Alan adını yerel makine çözer
curl_setopt($ch, CURLOPT_PROXY, 'socks5://user:pass@pr.proxynet.io:1080');

Denemelerimizde iki tuzak çıktı. Birincisi: adrese port yazmazsanız libcurl varsayılan olarak 1080 portunu dener, çünkü libcurl belgesindeki CURLOPT_PROXY tanımı bunu böyle söylüyor. HTTP proxy'nizi portsuz yazdığınızda gelen "could not connect" hatası bu yüzdendir.

İkincisi, yanlış şifrenin iki farklı biçimde görünmesi. HTTPS bir adrese giderken proxy bir tünel kurar; şifre yanlışsa tünel hiç kurulmaz ve curl_exec false döner. CURLINFO_RESPONSE_CODE size 0 gösterir, gerçek 407 ancak CURLINFO_HTTP_CONNECTCODE içinde durur. Aynı istek HTTP bir adrese gittiğinde ise normal bir yanıt gelir ve durum kodu düpedüz 407 olur. Yani proxy şifresini kontrol eden kodun her iki alanı da okuması gerekir:

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

if ($status === 407 || $tunnel === 407) {
    throw new RuntimeException('Proxy kimlik bilgileri reddedildi');
}

Hangi proxy türünü seçeceğiniz hedefe bağlıdır. Kendi sunucunuza ya da kısıtlaması olmayan bir kaynağa giden yoğun trafikte Datacenter Proxy en ucuz çözümdür. Veri merkezi adreslerini kısıtlayan sitelerde Residential Proxy gerekir. Çıkış adresini istek başına değiştirmek istediğinizde Rotating Proxy, oturum boyunca aynı adreste kalmanız gerektiğinde Sticky Proxy kullanılır.

Guzzle ile istek göndermek ve hataları yakalamak

Tek sayfalık işlerde cURL yeterlidir. Onlarca sayfayı düzenli olarak gezen bir iş yazıyorsanız Guzzle, elle yazacağınız üç dört yüz satırı hazır veriyor: yeniden deneme ara katmanı, eşzamanlı istek havuzu ve durum koduna göre ayrışan istisnalar. Proxy ayarı da tek satıra iniyor, çünkü Guzzle istek seçenekleri belgesine göre proxy seçeneği ya tek bir dizge ya da protokole göre ayrışan bir dizi alıyor.

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('katalog/sayfa-1.html');
} catch (BadResponseException $e) {
    // Sunucu 4xx ya da 5xx döndü; yanıt nesnesi istisnanın içinde
    exit('HTTP ' . $e->getResponse()->getStatusCode() . PHP_EOL);
} catch (TransferException $e) {
    // Yanıt hiç gelmedi: DNS, zaman aşımı, proxy tüneli (407 dahil)
    exit('Network error: ' . $e->getMessage() . PHP_EOL);
}

$crawler = new Crawler((string) $response->getBody(), 'https://example.com/katalog/sayfa-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 nesnesine ikinci parametre olarak sayfanın adresini vermek küçük ama kritik bir ayrıntıdır: bu olmadan link()->getUri() göreli adresi mutlak adrese çeviremez ve istisna fırlatır. Sayfalama mantığının tamamını Web Scraping'de Sayfalama yazımızda ele aldık. Bir uyarı da text() için: DomCrawler belgesinin belirttiği gibi seçici hiçbir şey bulamadığında istisna fırlatır, eksik alanın işi durdurmaması için varsayılan değer verin (->text('')).

İstisna sınıflarının iki ana dalı var ve ikisi de TransferException altından iniyor:

İstisnaNe zamanYanıt nesnesi var mı
ClientException4xx yanıt (404, HTTP hedefte 407)Var
ServerException5xx yanıtVar
ConnectExceptionBağlantı kurulamadı, kapalı port, zaman aşımıYok
TransferException (üst sınıf)Tünel kurulamadı, HTTPS hedefte 407Yok

Guzzle 8, bağlantı hataları için NetworkException ve ConnectTimeoutException gibi daha ayrıntılı sınıflar ekledi. İki sürümde de çalışan kod için yakalama sırasını yukarıdaki gibi kurun: önce BadResponseException, sonra TransferException.

Guzzle varsayılan olarak 4xx ve 5xx yanıtlarda istisna fırlatır. Yüzlerce adresi gezen bir işte durum kodunu değer olarak okumak daha rahattır: 'http_errors' => false verdiğinizde 404 gelen istek sessizce 404 kodlu bir yanıt nesnesi döndürür.

robots.txt, bekleme ve yeniden deneme

Kodun çalışması yetmez, düzgün davranması gerekir. Üç kural var.

robots.txt okunur. Standart RFC 9309 ile tanımlı ve dört davranışı açıkça söylüyor: en uzun eşleşen kural kazanır, allow ile disallow eşit uzunluktaysa allow kazanır, dosya 4xx dönerse kısıt yok sayılır, 5xx dönerse her yol yasak sayılır. Aşağıdaki iki fonksiyon bu dört kuralı uyguluyor ve art arda yazılmış User-agent satırlarını tek bir grup olarak birleştiriyor:

php
const BOT_TOKEN = 'price-sync';   // User-Agent başlığımızdaki ad

// robots.txt içinden bize uyan grubun Allow/Disallow satırlarını çıkarır.
function loadRobotsRules(Client $client): array
{
    $response = $client->get('/robots.txt');
    $status = $response->getStatusCode();
    if ($status >= 500) {
        return [['disallow', '/']];   // ulaşılamadı: her yol yasak
    }
    if ($status >= 400) {
        return [];                    // dosya yok: kısıt yok
    }

    $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];   // yeni grup başlıyor
            }
            $agents[] = strtolower($value);
            continue;
        }
        $inRules = true;
        foreach ($agents as $agent) {
            $groups[$agent][] = [$field, $value];
        }
    }

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

// En uzun eşleşen kural kazanır; eşitlikte Allow kazanır (RFC 9309).
function isAllowed(string $path, array $rules): bool
{
    [$bestLength, $allowed] = [-1, true];
    foreach ($rules as [$field, $pattern]) {
        if ($pattern === '') {
            continue;   // boş Disallow: kısıt yok
        }
        $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;
}

İstekler arasında beklenir. Guzzle'ın delay seçeneği her isteğin önüne milisaniye cinsinden bekleme koyar. Bir saniye çoğu iş için makul bir başlangıçtır ve eşzamanlılık düşük tutulur. Kural basittir: sitenin normal ziyaretçi trafiğinin yanında fark edilmeyecek kadar yavaş olun.

Hatalara koda göre tepki verilir. Guzzle'ın Middleware::retry ara katmanı iki geri çağırım alır: hangi durumda yeniden deneneceğine karar veren ve ne kadar bekleneceğini söyleyen. Test sunucumuzda iki kez 503 ve Retry-After: 1 dönen bir adres iki saniye içinde üçüncü denemede 200 verdi; 404 dönen adres ise hiç yeniden denenmedi.

php
// Geçici hatalarda en fazla 3 kez yeniden dener; 403, 404, 407 gibi kodlarda denemez.
function retryMiddleware(): callable
{
    $decider = function (int $retries, $request, ?ResponseInterface $response = null): bool {
        if ($retries >= 3) {
            return false;
        }
        if ($response === null) {
            return true;   // yanıt gelmedi: bağlantı koptu ya da zaman aşımı
        }
        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;   // sunucunun verdiği süre
        }
        return (2 ** $retries) * 1000 + random_int(0, 500);
    };

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

Beklemeye eklenen rastgele bileşen tesadüf değil: aynı anda başarısız olan isteklerin aynı anda yeniden denenip yeni bir yığılma üretmesini engeller. Listede 403, 404 ve 407 yok, çünkü bu kodlar beklemekle değişmez; onlarda iş durmalı ve nedeni günlüğe yazılmalıdır.

Tam örnek: fiyat ve stok senkronizasyonu

Parçaları birleştirelim. Aşağıdaki akış robots.txt'yi okuyor, liste sayfalarını gezip ürün adreslerini topluyor (collectProductUrls, "sonraki sayfa" bağlantısını izleyen basit bir döngü), ürün sayfalarını ikişer ikişer çekiyor, tablodan fiyat ve stok çıkarıp SQLite'a yazıyor. Yerel test proxy'si üzerinden çalıştırdığımızda 40 ürünün 40'ını yaklaşık 32 saniyede kaydetti.

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
)');
// Aynı ürün ikinci kez geldiğinde satır eklenmez, fiyat ve stok güncellenir
$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,   // kodu istisna yerine değer olarak okuyoruz
    '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,                       // aynı anda açık istek sayısı
    'options'     => ['delay' => 1000],       // her istekten önce bekleme
    '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));

Ürün sayfasını okuyan parseProduct fonksiyonu, 200 yanıtını körü körüne kabul etmemek için küçük bir kontrolle başlıyor: beklenen başlık öğesi yoksa null dönüyor ve ana akış o adresi hata olarak kaydediyor.

php
function parseProduct(string $html, string $url): ?array
{
    $crawler = new Crawler($html, $url);
    if ($crawler->filter('.product_main h1')->count() === 0) {
        return null;   // 200 geldi ama beklenen öğe yok
    }

    // <table> satırlarını "başlık => değer" dizisine çevir
    $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 için iki not: betiği web sunucusu üzerinden değil komut satırından koşturun (php /yol/sync.php), çünkü web tarafındaki varsayılan çalışma süresi sınırı uzun bir işi ortadan keser. Proxy kimlik bilgilerini de koda değil ortam değişkenine koyun; ayrıntısı wget ile Proxy Kullanımı yazımızda.

JavaScript ile yüklenen sayfada PHP'nin sınırı

Buraya kadar anlatılan her şey tek bir varsayıma dayanıyor: istediğiniz veri, sunucunun gönderdiği ilk HTML'in içinde. Modern sitelerin önemli bir kısmında bu doğru değildir. Sunucu boş bir iskelet gönderir, ürün listesini tarayıcıda çalışan JavaScript sonradan getirir. Bu sayfayı cURL ile çektiğinizde seçicileriniz hiçbir şey bulamaz, çünkü aradığınız etiketler HTML'e hiç yazılmamıştır.

Burası PHP'nin sınırıdır ve kütüphane seçimiyle ilgisi yoktur. Guzzle de DomCrawler da gelen metni ayrıştırır, hiçbiri JavaScript çalıştırmaz. PHP ile tarayıcı sürmek isterseniz Panther gibi bir paketle Chrome'u dışarıdan çalıştırmanız gerekir, yani iş artık PHP'de değil tarayıcıdadır.

İyi haber şu: çoğu durumda tarayıcıya hiç gerek yoktur. Sayfanın arka planda çağırdığı JSON isteğini geliştirici araçlarındaki Network panelinde bulup aynı adresi doğrudan Guzzle ile çağırabilirsiniz; sonuç zaten yapılandırılmış veri olduğu için ayrıştırma adımı da ortadan kalkar. Bir sayfanın dinamik olup olmadığını nasıl anlayacağınızı Statik ve Dinamik Sayfalar yazımızda adım adım gösterdik.

Kullanım alanları

  • Tedarikçi fiyatını kendi panelinize çekmek: Gece çalışan bir cron işi ürün listesini gezip fiyat ve stok alanlarını günceller; kurgu veri kazıma çözümü sayfamızda.
  • Rakip fiyatlarını izlemek: Aynı ürünün birkaç sitedeki fiyatını günlük kaydetmek; Rakip Fiyat Takibi yazımızda ve fiyat takibi sayfamızda.
  • Kendi sitenizi taramak: Kırık bağlantı ve eksik başlık aramak için kendi alan adınızı gezmek; web tarayıcı sayfamızda.
  • Pazaryerindeki listelerinizi doğrulamak: Stok ve başlık alanlarının panelinizle uyuştuğunu kontrol etmek; e-ticaret çözümleri sayfamızda.
  • Kamuya açık tabloları okumak: Kurum sitelerindeki döviz, tarife ya da ilan tablolarını almak; yöntemin diller üstü özeti Web Sitesinden Veri Çekme yazımızda.

Sık yapılan hatalar

  • 200 yanıtını içeriğe bakmadan kabul etmek. Doğrulama ve hata sayfaları da 200 döner; her ayrıştırmadan önce beklediğiniz bir öğenin varlığını kontrol edin.
  • Zaman aşımı koymamak. Cevap vermeyen tek bir sayfa, default_socket_timeout değeri yüzünden betiği bir dakika bekletir. İki zaman aşımını da açıkça verin.
  • Fiyatı metin olarak kaydetmek. £51.77 dizgesini olduğu gibi saklarsanız karşılaştırma ve toplama yapamazsınız. Sayıya çevirin, para birimini ayrı sütuna koyun.
  • Sınıf adını eşitlikle aramak. @class="product_pod" yazan bir XPath, class="product_pod col-xs-6" etiketini bulamaz.
  • Proxy adresine port yazmamak. libcurl varsayılan olarak 1080'i dener ve hata mesajı sizi yanıltır.
  • 407 hatasını hedef sitede aramak. Bu kod proxy'den gelir; kullanıcı adını, şifreyi ve whitelist'i kontrol edin.
  • Eşzamanlılığı açgözlü ayarlamak. Yirmi paralel istek işi hızlandırmaz, sizi hız sınırına çarptırır.
  • Kimlik bilgilerini koda gömmek. Proxy kullanıcı adı ve şifresi sürüm kontrolüne girmemeli.

Karar rehberi

İhtiyaçÖneri
Tek sayfadan birkaç alancurl_* + DOMDocument ve XPath
PHP 8.4 ve CSS seçici alışkanlığıDom\HTMLDocument ile querySelectorAll
Onlarca sayfalık düzenli işGuzzle + DomCrawler, yeniden deneme ara katmanı
Liste sayfaları arasında gezmeDomCrawler link()->getUri() ile mutlak adres
Aynı IP'den çok istek, 429 alıyorsunuzHızı düşürün, sonra Rotating Proxy
Veri merkezi adresleri kısıtlanıyorResidential Proxy
Oturum boyunca aynı adres gerekiyorSticky Proxy
İçerik JavaScript ile geliyorÖnce arka plandaki JSON isteğini arayın
Site resmi API sunuyorScraping yerine API

Sıkça sorulan sorular

PHP web scraping için uygun bir dil mi?

Evet, ama sınırını bilerek. HTTP isteği ve HTML ayrıştırma tarafında araçlar olgun: cURL uzantısı libcurl'ün tamamını açar, Guzzle eşzamanlılık ve yeniden deneme sağlar, DomCrawler ile XPath güçlü seçiciler verir. Zayıf olduğu yer tarayıcı otomasyonudur. Zaten PHP ile yazılmış bir sisteme veri akıtacaksanız işi PHP'de tutmak, veriyi ikinci bir dilden taşımaktan basittir.

Simple HTML DOM kütüphanesini kullanmalı mıyım?

Türkçe örneklerde çok geçen bu kütüphane uzun süredir bakım görmüyor ve büyük sayfalarda belirgin biçimde yavaş çalışıyor. Aynı işi PHP'nin çekirdeğindeki DOMDocument bağımlılıksız yapıyor, PHP 8.4'te Dom\HTMLDocument ile HTML5 uyumlu hale geliyor, jQuery benzeri bir arayüz istiyorsanız Symfony DomCrawler var. Yeni bir proje için üçünden birini seçin.

cURL ile Guzzle arasında ne fark var?

Guzzle zaten arka planda cURL'ü kullanır; fark soyutlama düzeyindedir. Tek bir sayfa çekiyorsanız curl_* fonksiyonları yeter ve paket kurmanız gerekmez. Yeniden deneme, istek havuzu, ara katman ve durum koduna göre ayrışan istisnalar istiyorsanız Guzzle kullanın. Proxy ayarı ikisinde de birkaç satırdır.

Proxy kullanırken 407 hatası alıyorum, ne yapmalıyım?

407 hedef siteden değil proxy'den gelir ve kimlik doğrulamanın başarısız olduğunu söyler. Önce kullanıcı adı ile şifreyi kontrol edin. Şifrede @ ya da : varsa adres içinde kodlanmış olması gerekir; CURLOPT_PROXYUSERPWD seçeneğini ayrı kullanmak bu sorunu ortadan kaldırır. IP whitelist yöntemindeyseniz sunucunuzun çıkış adresinin listede olduğunu doğrulayın. HTTPS isteklerde 407'nin CURLINFO_RESPONSE_CODE yerine CURLINFO_HTTP_CONNECTCODE içinde göründüğünü de unutmayın.

Çektiğim sayfada Türkçe karakterler bozuk geliyor, neden?

Büyük olasılıkla DOMDocument::loadHTML kullanıyorsunuz ve sayfada <meta charset> etiketi yok. Bu durumda ayrıştırıcı içeriği UTF-8 saymaz. Çözüm ya HTML'in başına <?xml encoding="UTF-8"> eklemek ya da PHP 8.4'teki Dom\HTMLDocument::createFromString metoduna geçmektir; ikincisi kodlamayı kendisi doğru saptar. Yanıtın sıkıştırılmış gelmesi de benzer bir bozulmaya yol açabilir, bunun için CURLOPT_ENCODING seçeneğini boş dizgeyle verin.

Kaç saniyede bir istek göndermeliyim?

Sabit bir sayı yok, ama iki ölçü işe yarar: hedefin büyüklüğü (küçük bir kurum sitesiyle büyük bir pazaryeri aynı yükü kaldırmaz) ve sitenin kendi tepkisi (429 almaya başladıysanız hızınız fazladır ve Retry-After süresine uymanız gerekir). Pratik bir başlangıç, istekler arasında bir saniye beklemek ve eşzamanlılığı ikiyle sınırlamaktır.

Özetle

PHP ile veri çekmek, curl_* ile isteği göndermek ve gelen HTML'i DOMDocument + XPath ya da DomCrawler ile ayrıştırmaktan ibarettir. file_get_contents ve düzenli ifade, durum kodunu ve etiket varyasyonlarını görmediği için ilk gerçek sayfada kırılır. İş birkaç sayfayı aşınca Guzzle'ın yeniden deneme ara katmanı ve istek havuzu devreye girer. Proxy tarafında CURLOPT_PROXY ile CURLOPT_PROXYUSERPWD yeterlidir; portu yazmayı ve 407 hatasını proxy tarafında aramayı unutmayın. Asıl belirleyici olan ise koddan önce gelen üç karardır: robots.txt'ye uymak, istekler arasında beklemek ve yalnızca olgu niteliğindeki veriyi çekmek. Uygun proxy türlerini proxy hizmetlerimizde bulabilirsiniz.

ChatGPT'ye sorClaude'a sor