---
title: "PHP ile Siteden Veri Çekme: cURL, Guzzle ve Proxy Kullanımı"
description: "PHP'de veri çekmenin çekirdeği cURL ile istek, DOM ve XPath ile ayrıştırmadır. Guzzle, proxy ayarı, robots.txt ve yeniden denemeyi denenmiş kodla anlatıyoruz."
url: https://proxynet.io/tr/blog/php-web-scraping
date: 2026-09-19
author: "Acar Diveroli"
category: "Nasıl Yapılır, Web Scraping"
lang: tr
---

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

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.

> **Not: Kısa cevap**
>
> PHP'de veri çekmenin çekirdeği iki adımdır: sayfayı `curl_*` fonksiyonlarıyla (ya da Guzzle ile) indirmek, gelen HTML'i `DOMDocument` + XPath ya da Symfony DomCrawler ile ayrıştırmak. `file_get_contents` ve düzenli ifade küçük denemelerde çalışır ama durum kodunu, zaman aşımını ve iç içe etiketleri kaldıramaz. Proxy, cURL'de `CURLOPT_PROXY` ve `CURLOPT_PROXYUSERPWD` ile, Guzzle'da tek satırlık `proxy` seçeneğiyle verilir. İşin asıl belirleyici kısmı kodun kendisi değil, robots.txt'ye uymak, istekler arasında beklemek ve hangi hatada yeniden deneyip hangisinde durduğunuza karar vermektir.

## 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ı?](/tr/blog/web-scraping-legal) yazımızda, robots.txt söz dizimini [robots.txt Nedir?](/tr/blog/robots-txt) 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?](/tr/blog/curl-proxy) 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](https://www.php.net/manual/en/function.curl-setopt.php).

İ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ı](/tr/blog/http-status-codes-web-scraping) 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](https://www.php.net/manual/en/migration84.new-features.php) 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](/tr/blog/css-selector-vs-xpath) yazımızda bulabilirsiniz.

## Hangi katmanı seçmeli?

| Katman | Ne için | Artısı | Eksisi |
|---|---|---|---|
| `file_get_contents` | Tek seferlik deneme | Sıfır kurulum | Durum kodu ve zaman aşımı görünmez |
| `curl_*` fonksiyonları | Tek sayfa, az bağımlılık | Çekirdekte var, her seçenek elinizde | Her isteği elle kurarsınız |
| Guzzle | Çok sayfalı düzenli iş | Yeniden deneme, eşzamanlılık, temiz istisnalar | Composer bağımlılığı |
| Düzenli ifade | Hiçbiri | Kısa görünür | Boşluk ve tırnak farkında kırılır |
| `DOMDocument` + XPath | Çekirdekle ayrıştırma | Bağımlılık yok, XPath güçlü | Kodlama ipucu ve `libxml` gürültüsü |
| `Dom\HTMLDocument` | PHP 8.4 ve üstü | HTML5 uyumlu, `querySelector` var | Eski sürümlerde yok |
| Symfony DomCrawler | Liste ve bağlantı gezme | CSS 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](/tr/blog/proxy-authentication-methods) 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ı](https://curl.se/libcurl/c/CURLOPT_PROXY.html) 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](https://proxynet.io/tr/datacenter-proxy) en ucuz çözümdür. Veri merkezi adreslerini kısıtlayan sitelerde [Residential Proxy](https://proxynet.io/tr/residential-proxy) gerekir. Çıkış adresini istek başına değiştirmek istediğinizde [Rotating Proxy](https://proxynet.io/tr/rotating-proxy), oturum boyunca aynı adreste kalmanız gerektiğinde [Sticky Proxy](https://proxynet.io/tr/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](https://docs.guzzlephp.org/en/stable/request-options.html) `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](/tr/blog/pagination-web-scraping) yazımızda ele aldık. Bir uyarı da `text()` için: [DomCrawler belgesinin](https://symfony.com/doc/current/components/dom_crawler.html) 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:

| İstisna | Ne zaman | Yanıt nesnesi var mı |
|---|---|---|
| `ClientException` | `4xx` yanıt (`404`, HTTP hedefte `407`) | Var |
| `ServerException` | `5xx` yanıt | Var |
| `ConnectException` | Bağlantı kurulamadı, kapalı port, zaman aşımı | Yok |
| `TransferException` (üst sınıf) | Tünel kurulamadı, HTTPS hedefte `407` | Yok |

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](https://www.rfc-editor.org/rfc/rfc9309.html) 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ı](/tr/blog/wget-proxy) 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](/tr/blog/static-vs-dynamic-pages) 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ü](/tr/data-scraping) sayfamızda.
- **Rakip fiyatlarını izlemek:** Aynı ürünün birkaç sitedeki fiyatını günlük kaydetmek; [Rakip Fiyat Takibi](/tr/blog/competitor-price-tracking) yazımızda ve [fiyat takibi](/tr/price-monitoring) 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ı](/tr/web-crawler) sayfamızda.
- **Pazaryerindeki listelerinizi doğrulamak:** Stok ve başlık alanlarının panelinizle uyuştuğunu kontrol etmek; [e-ticaret çözümleri](/tr/e-commerce-proxy) 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](/tr/blog/extract-data-from-website) 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ç alan | `curl_*` + `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 gezme | DomCrawler `link()->getUri()` ile mutlak adres |
| Aynı IP'den çok istek, `429` alıyorsunuz | Hızı düşürün, sonra [Rotating Proxy](https://proxynet.io/tr/rotating-proxy) |
| Veri merkezi adresleri kısıtlanıyor | [Residential Proxy](https://proxynet.io/tr/residential-proxy) |
| Oturum boyunca aynı adres gerekiyor | [Sticky Proxy](https://proxynet.io/tr/sticky-proxy) |
| İçerik JavaScript ile geliyor | Önce arka plandaki JSON isteğini arayın |
| Site resmi API sunuyor | Scraping 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](/tr/proxy) bulabilirsiniz.
