---
title: "Webscraping mit PHP: cURL, Guzzle und Proxy-Nutzung"
description: "Der Kern des Scrapings in PHP ist die cURL-Anfrage und das Parsen mit DOM und XPath. Wir zeigen Guzzle, Proxy, robots.txt und Wiederholungen mit erprobtem Code."
url: https://proxynet.io/de/blog/php-web-scraping
date: 2026-09-19
author: "Acar Diveroli"
category: "Anleitungen, Web Scraping"
lang: de
---

# Webscraping mit PHP: cURL, Guzzle und Proxy-Nutzung

Die Preisliste eines Lieferanten ändert sich zweimal pro Woche, und Sie tragen diese Liste von Hand in Ihr eigenes Panel ein. Die Website bietet weder eine API noch eine Datei zum Herunterladen; die Daten stehen nur in der HTML-Seite. Ihr Projekt ist in PHP geschrieben, also suchen Sie die Lösung auch auf der PHP-Seite: Sie soll auf demselben Server laufen, in dieselbe Datenbank schreiben und nachts einmal per Cron starten.

In diesem Beitrag zeigen wir, wie Sie mit den Bordmitteln von PHP strukturierte Daten aus einer Seite holen. Der Reihe nach: die Anfrage mit cURL senden, HTML mit `DOMDocument` und XPath parsen, der neue HTML5-Parser von PHP 8.4, Fehlerbehandlung und gleichzeitige Anfragen mit Guzzle, die Proxy-Einstellung (`CURLOPT_PROXY`, SOCKS5 und die Option `proxy` in Guzzle), robots.txt lesen, warten und wiederholen. Am Ende steht ein vollständiges Beispiel, das die Daten per PDO in eine Datenbank schreibt. Alle Codebeispiele liefen unter PHP 8.4 gegen `books.toscrape.com` und über einen lokalen Test-Proxy.

> **Hinweis: Kurzantwort**
>
> Scraping in PHP besteht aus zwei Schritten: die Seite mit den `curl_*`-Funktionen (oder mit Guzzle) herunterladen und das HTML mit `DOMDocument` + XPath oder mit Symfony DomCrawler parsen. `file_get_contents` und reguläre Ausdrücke funktionieren für einen kurzen Versuch, kommen aber mit Statuscode, Zeitlimit und verschachtelten Tags nicht zurecht. Den Proxy setzen Sie in cURL über `CURLOPT_PROXY` und `CURLOPT_PROXYUSERPWD`, in Guzzle über die einzeilige Option `proxy`. Entscheidend ist am Ende nicht der Code, sondern dass Sie robots.txt beachten, zwischen den Anfragen warten und festlegen, bei welchem Fehler Sie es erneut versuchen und bei welchem Sie abbrechen.

## Datenextraktion ist nicht dasselbe wie Inhalte kopieren

In diesem Beitrag geht es nicht darum, fremde Inhalte erneut zu veröffentlichen. Einen Artikel, einen Nachrichtentext oder eine Filmseite von einer anderen Website zu holen und auf der eigenen Seite zu veröffentlichen, ist eine Urheberrechtsverletzung, und es spielt keine Rolle, ob das mit PHP oder mit einer anderen Sprache geschieht.

Worum es hier geht, ist **strukturierte Datenextraktion**: der Preis eines Produkts, der Lagerbestand, der Titel, die Zeilen einer Tabelle, die Domainnamen einer Liste. Das sind meist einzelne Tatsachen, die kein schöpferisches Werk bilden. Die Preisliste Ihres eigenen Lieferanten in Ihr eigenes Panel zu übernehmen, eine öffentlich zugängliche Behördentabelle zu lesen oder den Lagerstand Ihrer eigenen Produkte auf einem Marktplatz zu verfolgen, gehört in diese Gruppe.

In der Praxis ziehen Sie die Grenze mit drei Fragen. Holen Sie einen Textblock oder einen Feldwert? Nutzen Sie die Daten im eigenen Geschäft, oder veröffentlichen Sie eine Seite, die an die Stelle der Quelle tritt? Was sagen die Nutzungsbedingungen und die robots.txt der Website zu diesem Zugriff? Die rechtliche Seite behandeln wir in [Ist Web Scraping legal?](/de/blog/is-data-web-scraping-legal), die Syntax von robots.txt in [Was ist robots.txt?](/de/blog/robots-txt).

Es gibt noch eine technische Grenze: Wenn die Seite ohne Anmeldung nicht sichtbar ist, wenn die Bedingungen automatisierten Zugriff ausdrücklich untersagen oder wenn die Daten personenbezogene Angaben enthalten, ist kein Code aus diesem Beitrag geeignet. Prüfen Sie zuerst, ob es eine offizielle API gibt.

## Wie funktioniert Scraping mit PHP?

Es sind vier Schritte, und die ändern sich nicht mit der Sprache; nur die verwendete Bibliothek wechselt.

1. **Die Anfrage wird gesendet.** Eine HTTP-GET-Anfrage lädt das HTML der Seite. Hier werden der `User-Agent`-Header, das Zeitlimit, das Verfolgen von Weiterleitungen und gegebenenfalls der Proxy gesetzt.
2. **Die Antwort wird geprüft.** Der Statuscode wird gelesen. Ein `200` bedeutet nicht, dass Sie die Daten haben; prüfen Sie, ob das erwartete Element wirklich auf der Seite steht.
3. **Das HTML wird geparst.** Der eingegangene Text wird in einen Baum umgewandelt, und die gewünschten Felder werden mit Selektoren (CSS-Selektor oder XPath) herausgeholt.
4. **Die Daten werden gespeichert.** Die Werte werden in ihren Typ umgewandelt (Preis von Text zu Dezimalzahl) und in eine Datenbank oder eine Datei geschrieben.

Auf der Anfrageseite haben Sie zwei Möglichkeiten (`curl_*`-Funktionen und Guzzle), auf der Parserseite ebenfalls zwei (`DOMDocument` und Symfony DomCrawler). Unten sehen Sie alle einzeln.

## Wie lädt man eine Seite mit cURL herunter?

Die cURL-Erweiterung von PHP öffnet libcurl, die Bibliothek hinter dem Kommandozeilenwerkzeug `curl`, für PHP. Die Optionsnamen folgen derselben Logik, deshalb lässt sich eine im Terminal erprobte Anfrage leicht in Code übersetzen. Die Entsprechungen der Flags auf der Kommandozeile finden Sie in [Proxy mit cURL verwenden](/de/blog/curl-proxy).

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

// Lädt eine einzelne Seite per cURL; prüft Fehler, Statuscode und Zeitlimit.
function fetchPage(string $url): string
{
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,   // Antwort nicht ausgeben, sondern zurückgeben
        CURLOPT_FOLLOWLOCATION => true,   // 301/302-Weiterleitungen folgen
        CURLOPT_MAXREDIRS      => 5,
        CURLOPT_CONNECTTIMEOUT => 10,     // Zeit für den Verbindungsaufbau (Sekunden)
        CURLOPT_TIMEOUT        => 30,     // Gesamtzeit der Anfrage (Sekunden)
        CURLOPT_ENCODING       => '',     // gzip/deflate-Antworten selbst entpacken
        CURLOPT_USERAGENT      => 'price-sync/1.0 (+https://example.com/bot)',
    ]);

    $body = curl_exec($ch);
    if ($body === false) {
        // Netzwerkfehler: DNS, abgelehnte Verbindung, Zeitüberschreitung
        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;
}
```

Einige Optionen sind besonders wichtig. Ohne `CURLOPT_RETURNTRANSFER` gibt cURL die Antwort direkt aus, und `curl_exec` liefert Ihnen nur `true`. Wird `CURLOPT_ENCODING` eine leere Zeichenkette übergeben, entpackt cURL die komprimierte Antwort selbst; lassen Sie das weg, kommen von manchen Websites unlesbare Binärdaten. Dass es zwei getrennte Zeitlimits gibt, ist ebenfalls kein Zufall: `CURLOPT_CONNECTTIMEOUT` begrenzt den Verbindungsaufbau, `CURLOPT_TIMEOUT` die gesamte Anfrage. Die vollständige Liste der Optionen steht auf [der Seite zu `curl_setopt` bei php.net](https://www.php.net/manual/en/function.curl-setopt.php).

Beachten Sie, dass wir zwei Fehlerarten getrennt abfangen. Gibt `curl_exec` `false` zurück, ist überhaupt keine Antwort eingetroffen; das ist ein Fehler der Netzwerkschicht. Kam eine Antwort und ist der Code nicht `200`, liegt das Problem auf der Serverseite, und die Reaktion hängt vom Code ab. Welche Codes zum Abbruch führen und welche einen neuen Versuch rechtfertigen, haben wir in [HTTP-Statuscodes beim Scraping](/de/blog/http-status-codes-web-scraping) in einer Tabelle gesammelt.

## Warum reichen file_get_contents und reguläre Ausdrücke nicht?

Die meisten Anleitungen beginnen mit `file_get_contents`. Eine einzige Zeile wirkt verlockend, aber sie verdeckt drei Dinge.

Erstens den Statuscode. Als wir eine nicht vorhandene Seite abgerufen haben, erzeugte die Funktion eine Warnung und gab `false` zurück; der einzige Weg zum Code führte über die erste Zeile des Arrays `$http_response_header`, das nach dem Aufruf wie von selbst auftaucht. Zweitens das Zeitlimit: Der Standardwert von `default_socket_timeout` liegt bei 60 Sekunden, eine einzige nicht antwortende Seite hält Ihr Skript also eine Minute lang auf. Drittens Proxy- und Header-Einstellungen: beides geht nur, wenn Sie von Hand einen `stream_context_create`-Block schreiben. Genau das erledigt cURL bereits.

Der zweite Klassiker ist, HTML mit einem regulären Ausdruck zu parsen. Ein einziges Beispiel genügt, um zu zeigen, warum das bricht. Von den beiden Preis-Tags unten enthält eines einen Zeilenumbruch, das andere verwendet einfache statt doppelter Anführungszeichen:

```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
```

Der reguläre Ausdruck hat keines gefunden, der Parser beide. Auf echten Seiten sind diese beiden Unterschiede die Regel und nicht die Ausnahme; dazu kommen Reihenfolge der Klassen, zusätzliche Attribute und verschachtelte Tags. Statt das Muster jedes Mal komplizierter zu machen, nehmen Sie von Anfang an einen Parser.

## HTML parsen: DOMDocument, XPath und PHP 8.4

Im Kern von PHP stecken zwei Parser. Der ältere ist `DOMDocument`, der neuere der mit PHP 8.4 eingeführte Namensraum `Dom`. [Laut der Seite zu den Neuerungen in PHP 8.4](https://www.php.net/manual/en/migration84.new-features.php) sind die neuen Klassen HTML5-fähig und folgen der WHATWG-Spezifikation; die alten Klassen bleiben aus Gründen der Abwärtskompatibilität bestehen.

In der Praxis gibt es drei Unterschiede. `DOMDocument::loadHTML` erzeugt bei den HTML-Fehlern echter Seiten Warnungen, deshalb ist vor dem Aufruf `libxml_use_internal_errors(true)` nötig; die neue Klasse erzeugt dieses Rauschen nicht. Der zweite ist die Selektorunterstützung: `Dom\HTMLDocument` bringt die aus dem Browser bekannten Methoden `querySelector` und `querySelectorAll` mit.

Der dritte Unterschied betrifft alle, die Seiten mit Sonderzeichen auslesen: die Zeichenkodierung. Als wir dem alten Parser ein UTF-8-Fragment ohne `<meta charset>`-Tag übergeben haben, kamen die Umlaute beschädigt zurück; dasselbe Fragment las der neue Parser korrekt. Wenn Sie mit der alten Klasse arbeiten müssen, geben Sie die Kodierung ausdrücklich an:

```php
$fragment = '<p class="price">Preis: 1.250 Euro (Türkiye, Izmir, äöüé)</p>';

$old = new DOMDocument();
libxml_use_internal_errors(true);
$old->loadHTML($fragment);
echo $old->getElementsByTagName('p')->item(0)->textContent;
// Preis: 1.250 Euro (TÃ¼rkiye, Izmir, Ã¤Ã¶Ã¼Ã©)

$old2 = new DOMDocument();
$old2->loadHTML('<?xml encoding="UTF-8">' . $fragment);   // Kodierung ausdrücklich angeben
echo $old2->getElementsByTagName('p')->item(0)->textContent;
// Preis: 1.250 Euro (Türkiye, Izmir, äöüé)

// PHP 8.4: kein zusätzlicher Hinweis nötig
$new = Dom\HTMLDocument::createFromString($fragment, LIBXML_NOERROR);
echo $new->querySelector('p.price')->textContent;
// Preis: 1.250 Euro (Türkiye, Izmir, äöüé)
```

Alle Produktkarten einer Listenseite mit XPath zu durchlaufen, folgt derselben Logik. Die Schleife unten holt Titel, Preis und Lagerstatus aus den 20 Karten der Testseite:

```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)),
    ];
}
```

Wir schreiben `contains(@class, ...)`, weil das Attribut `class` meist mehr als einen Klassennamen trägt; die Gleichheit `@class="product_pod"` übersieht ein Tag mit `class="product_pod col-xs-6"`. Und `string(...)` liefert bei leerer Auswahl eine leere Zeichenkette statt einer Ausnahme. Einen Vergleich der beiden Selektorsprachen finden Sie in [CSS-Selektor und XPath](/de/blog/css-selector-vs-xpath).

## Welche Ebene soll man wählen?

| Ebene | Wofür | Vorteil | Nachteil |
|---|---|---|---|
| `file_get_contents` | Einmaliger Versuch | Kein Setup | Statuscode und Zeitlimit unsichtbar |
| `curl_*`-Funktionen | Eine Seite, wenige Abhängigkeiten | Im Kern enthalten, jede Option in Ihrer Hand | Jede Anfrage wird von Hand aufgebaut |
| Guzzle | Regelmäßige Arbeit über viele Seiten | Wiederholung, Gleichzeitigkeit, klare Ausnahmen | Abhängigkeit über Composer |
| Reguläre Ausdrücke | Für nichts davon | Wirkt kurz | Bricht bei Leerzeichen und Anführungszeichen |
| `DOMDocument` + XPath | Parsen mit Bordmitteln | Keine Abhängigkeit, XPath ist mächtig | Kodierungshinweis und `libxml`-Rauschen |
| `Dom\HTMLDocument` | PHP 8.4 und höher | HTML5-fähig, mit `querySelector` | Fehlt in älteren Versionen |
| Symfony DomCrawler | Listen und Links durchlaufen | CSS-Selektoren, `each()`, absolute Links | Zwei zusätzliche Pakete |

## Wie verwendet man einen Proxy mit cURL?

Eine Aufgabe, die regelmäßig viele Seiten abruft, stößt früher oder später an die Grenze einer einzigen Ausgangsadresse: Eine Website, die pro Minute Hunderte Anfragen von derselben IP sieht, antwortet mit `429` oder erkennt Rechenzentrumsblöcke und gibt `403` zurück. Ein Proxy ändert den Ausgangspunkt dieser Anfragen.

In cURL genügen zwei Optionen:

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

Sie können die Zugangsdaten auch in die Adresse schreiben (`CURLOPT_PROXY => 'http://user:pass@pr.proxynet.io:8000'`). Enthält das Passwort `@`, `:` oder `/`, ist die getrennte Option sicherer, weil diese Zeichen innerhalb einer Adresse kodiert werden müssen. Die beiden Authentifizierungsverfahren und die Alternative der IP-Whitelist haben wir in [Proxy-Authentifizierung](/de/blog/proxy-authentication-methods) beschrieben.

Für SOCKS5 müssen Sie zusätzlich den Proxy-Typ angeben. Entscheidend ist dabei, wer den Domainnamen auflöst:

```php
// Den Domainnamen löst der Proxy auf (socks5h): auch Ihre DNS-Anfrage läuft über den 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');

// Dasselbe in einer Zeile
curl_setopt($ch, CURLOPT_PROXY, 'socks5h://user:pass@pr.proxynet.io:1080');

// Den Domainnamen löst der lokale Rechner auf
curl_setopt($ch, CURLOPT_PROXY, 'socks5://user:pass@pr.proxynet.io:1080');
```

Bei unseren Versuchen traten zwei Fallen auf. Erstens: Schreiben Sie keinen Port in die Adresse, versucht libcurl standardmäßig Port 1080, denn [die Definition von `CURLOPT_PROXY` in der libcurl-Dokumentation](https://curl.se/libcurl/c/CURLOPT_PROXY.html) sagt es so. Daher kommt die Meldung „could not connect", wenn Sie Ihren HTTP-Proxy ohne Port angeben.

Zweitens zeigt sich ein falsches Passwort auf zwei verschiedene Arten. Auf dem Weg zu einer HTTPS-Adresse baut der Proxy einen Tunnel; ist das Passwort falsch, entsteht der Tunnel gar nicht erst, und `curl_exec` gibt `false` zurück. `CURLINFO_RESPONSE_CODE` zeigt Ihnen `0`, der echte `407` steckt nur in `CURLINFO_HTTP_CONNECTCODE`. Geht dieselbe Anfrage an eine HTTP-Adresse, kommt eine normale Antwort, und der Statuscode lautet schlicht `407`. Code, der das Proxy-Passwort prüft, muss also beide Felder lesen:

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

if ($status === 407 || $tunnel === 407) {
    throw new RuntimeException('Proxy-Zugangsdaten abgelehnt');
}
```

Welchen Proxy-Typ Sie wählen, hängt vom Ziel ab. Bei starkem Verkehr zu Ihrem eigenen Server oder zu einer Quelle ohne Einschränkungen sind [Datacenter-Proxy](https://proxynet.io/de/datacenter-proxy) die günstigste Lösung. Auf Websites, die Rechenzentrumsadressen einschränken, brauchen Sie [Residential-Proxy](https://proxynet.io/de/residential-proxy). Wenn Sie die Ausgangsadresse pro Anfrage wechseln wollen, nehmen Sie [Rotierender Proxy](https://proxynet.io/de/rotating-proxy), und wenn Sie während einer Sitzung auf derselben Adresse bleiben müssen, [Sticky-Proxy](https://proxynet.io/de/sticky-proxy).

## Anfragen senden und Fehler abfangen mit Guzzle

Bei einseitigen Aufgaben genügt cURL. Wenn Sie eine Aufgabe schreiben, die regelmäßig Dutzende Seiten durchläuft, liefert Guzzle die drei- bis vierhundert Zeilen fertig, die Sie sonst selbst schreiben müssten: eine Middleware für Wiederholungen, einen Pool für gleichzeitige Anfragen und Ausnahmen, die sich nach Statuscode unterscheiden. Auch die Proxy-Einstellung schrumpft auf eine Zeile, denn [laut der Dokumentation zu den Guzzle-Anfrageoptionen](https://docs.guzzlephp.org/en/stable/request-options.html) nimmt die Option `proxy` entweder eine einzelne Zeichenkette oder ein nach Protokoll aufgeteiltes Array an.

```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/seite-1.html');
} catch (BadResponseException $e) {
    // Der Server hat 4xx oder 5xx geliefert; das Antwortobjekt steckt in der Ausnahme
    exit('HTTP ' . $e->getResponse()->getStatusCode() . PHP_EOL);
} catch (TransferException $e) {
    // Gar keine Antwort: DNS, Zeitlimit, Proxy-Tunnel (einschließlich 407)
    exit('Network error: ' . $e->getMessage() . PHP_EOL);
}

$crawler = new Crawler((string) $response->getBody(), 'https://example.com/katalog/seite-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(),
]);
```

Dem `Crawler`-Objekt die Adresse der Seite als zweiten Parameter zu übergeben, ist ein kleines, aber entscheidendes Detail: Ohne sie kann `link()->getUri()` eine relative Adresse nicht in eine absolute umwandeln und wirft eine Ausnahme. Die gesamte Logik der Seitennummerierung behandeln wir in [Seitennummerierung beim Web Scraping](/de/blog/pagination-web-scraping). Noch ein Hinweis zu `text()`: Wie [die Dokumentation zu DomCrawler](https://symfony.com/doc/current/components/dom_crawler.html) festhält, wirft die Methode eine Ausnahme, wenn der Selektor nichts findet; geben Sie einen Standardwert an (`->text('')`), damit ein fehlendes Feld die Arbeit nicht anhält.

Es gibt zwei Hauptzweige von Ausnahmeklassen, und beide stammen von `TransferException` ab:

| Ausnahme | Wann | Antwortobjekt vorhanden |
|---|---|---|
| `ClientException` | `4xx`-Antwort (`404`, bei HTTP-Ziel `407`) | Ja |
| `ServerException` | `5xx`-Antwort | Ja |
| `ConnectException` | Verbindung gescheitert, geschlossener Port, Zeitlimit | Nein |
| `TransferException` (Oberklasse) | Tunnel nicht aufgebaut, bei HTTPS-Ziel `407` | Nein |

Guzzle 8 hat für Verbindungsfehler genauere Klassen wie `NetworkException` und `ConnectTimeoutException` ergänzt. Für Code, der in beiden Versionen läuft, ordnen Sie die Reihenfolge wie oben: zuerst `BadResponseException`, dann `TransferException`.

Standardmäßig wirft Guzzle bei `4xx`- und `5xx`-Antworten eine Ausnahme. In einer Aufgabe, die Hunderte Adressen durchläuft, ist es bequemer, den Statuscode als Wert zu lesen: Mit `'http_errors' => false` liefert eine Anfrage mit `404` still ein Antwortobjekt mit dem Code `404`.

## robots.txt, Warten und Wiederholen

Es genügt nicht, dass der Code läuft; er muss sich anständig verhalten. Es gibt drei Regeln.

**robots.txt wird gelesen.** Der Standard ist in [RFC 9309](https://www.rfc-editor.org/rfc/rfc9309.html) definiert und nennt vier Verhaltensweisen ausdrücklich: Die längste passende Regel gewinnt, bei gleichwertigen `allow`- und `disallow`-Regeln gewinnt `allow`, liefert die Datei `4xx`, gelten keine Einschränkungen, liefert sie `5xx`, gilt jeder Pfad als verboten. Die beiden Funktionen unten setzen diese vier Regeln um und fassen aufeinanderfolgende `User-agent`-Zeilen zu einer Gruppe zusammen:

```php
const BOT_TOKEN = 'price-sync';   // der Name in unserem User-Agent-Header

// Holt die Allow/Disallow-Zeilen der für uns geltenden Gruppe aus der robots.txt.
function loadRobotsRules(Client $client): array
{
    $response = $client->get('/robots.txt');
    $status = $response->getStatusCode();
    if ($status >= 500) {
        return [['disallow', '/']];   // nicht erreichbar: jeder Pfad verboten
    }
    if ($status >= 400) {
        return [];                    // keine Datei: keine Einschraenkung
    }

    $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];   // hier beginnt eine neue Gruppe
            }
            $agents[] = strtolower($value);
            continue;
        }
        $inRules = true;
        foreach ($agents as $agent) {
            $groups[$agent][] = [$field, $value];
        }
    }

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

// Die laengste passende Regel gewinnt; bei Gleichstand gewinnt Allow (RFC 9309).
function isAllowed(string $path, array $rules): bool
{
    [$bestLength, $allowed] = [-1, true];
    foreach ($rules as [$field, $pattern]) {
        if ($pattern === '') {
            continue;   // leeres Disallow: keine Einschraenkung
        }
        $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;
}
```

**Zwischen den Anfragen wird gewartet.** Die Option `delay` in Guzzle setzt vor jede Anfrage eine Wartezeit in Millisekunden. Eine Sekunde ist für die meisten Aufgaben ein vernünftiger Anfang, und die Gleichzeitigkeit bleibt niedrig. Die Regel ist einfach: Seien Sie so langsam, dass Sie neben dem normalen Besucherverkehr der Website nicht auffallen.

**Auf Fehler wird je nach Code reagiert.** Die Middleware `Middleware::retry` in Guzzle nimmt zwei Rückrufe entgegen: einen, der über den erneuten Versuch entscheidet, und einen, der die Wartezeit angibt. Auf unserem Testserver antwortete eine Adresse, die zweimal `503` mit `Retry-After: 1` lieferte, innerhalb von zwei Sekunden beim dritten Versuch mit `200`; eine Adresse mit `404` wurde nie erneut versucht.

```php
// Versucht temporaere Fehler hoechstens 3-mal erneut; bei Codes wie 403, 404, 407 nicht.
function retryMiddleware(): callable
{
    $decider = function (int $retries, $request, ?ResponseInterface $response = null): bool {
        if ($retries >= 3) {
            return false;
        }
        if ($response === null) {
            return true;   // keine Antwort: Verbindung abgebrochen oder Zeitlimit
        }
        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;   // die vom Server genannte Dauer
        }
        return (2 ** $retries) * 1000 + random_int(0, 500);
    };

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

Der Zufallsanteil in der Wartezeit ist kein Zufall: Er verhindert, dass gleichzeitig gescheiterte Anfragen gleichzeitig erneut starten und einen neuen Stau erzeugen. `403`, `404` und `407` fehlen in der Liste, weil Warten an diesen Codes nichts ändert; dort sollte die Arbeit abbrechen und der Grund protokolliert werden.

## Vollständiges Beispiel: Preis- und Bestandsabgleich

Fügen wir die Teile zusammen. Der Ablauf unten liest die robots.txt, durchläuft die Listenseiten und sammelt die Produktadressen (`collectProductUrls`, eine einfache Schleife entlang des Links „nächste Seite"), ruft die Produktseiten zu zweit ab, holt Preis und Bestand aus der Tabelle und schreibt sie nach SQLite. Über einen lokalen Test-Proxy ausgeführt, speicherte er alle 40 von 40 Produkten in rund 32 Sekunden.

```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
)');
// Kommt dasselbe Produkt ein zweites Mal, wird keine Zeile ergaenzt, sondern Preis und Bestand aktualisiert
$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,   // wir lesen den Code als Wert statt als Ausnahme
    '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,                       // Anzahl gleichzeitig offener Anfragen
    'options'     => ['delay' => 1000],       // Wartezeit vor jeder Anfrage
    '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));
```

Die Funktion `parseProduct`, die die Produktseite liest, beginnt mit einer kleinen Prüfung, um ein `200` nicht blind zu akzeptieren: Fehlt das erwartete Überschriftselement, liefert sie `null`, und der Hauptablauf verbucht diese Adresse als Fehler.

```php
function parseProduct(string $html, string $url): ?array
{
    $crawler = new Crawler($html, $url);
    if ($crawler->filter('.product_main h1')->count() === 0) {
        return null;   // 200 kam an, aber das erwartete Element fehlt
    }

    // Die <table>-Zeilen in ein Array "Ueberschrift => Wert" umwandeln
    $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'),
    ];
}
```

Zwei Hinweise für Cron: Starten Sie das Skript über die Kommandozeile und nicht über den Webserver (`php /pfad/sync.php`), weil das voreingestellte Zeitlimit auf der Webseite eine lange Aufgabe mittendrin abschneidet. Und legen Sie die Proxy-Zugangsdaten in eine Umgebungsvariable statt in den Code; Einzelheiten dazu in [Proxy mit wget verwenden](/de/blog/wget-proxy).

## Wo PHP bei einer per JavaScript geladenen Seite endet

Alles bisher Beschriebene beruht auf einer Annahme: Die gewünschten Daten stehen im ersten HTML, das der Server sendet. Bei einem erheblichen Teil moderner Websites stimmt das nicht. Der Server sendet ein leeres Gerüst, und die Produktliste holt das im Browser laufende JavaScript nach. Rufen Sie diese Seite mit cURL ab, finden Ihre Selektoren nichts, weil die gesuchten Tags nie ins HTML geschrieben wurden.

Hier endet PHP, und das hat nichts mit der Wahl der Bibliothek zu tun. Guzzle und DomCrawler parsen beide den eingegangenen Text, keiner von beiden führt JavaScript aus. Wollen Sie aus PHP heraus einen Browser steuern, müssen Sie mit einem Paket wie Panther Chrome von außen starten; damit liegt die Arbeit nicht mehr in PHP, sondern im Browser.

Die gute Nachricht: In den meisten Fällen brauchen Sie überhaupt keinen Browser. Die JSON-Anfrage, die die Seite im Hintergrund stellt, finden Sie im Netzwerk-Tab der Entwicklerwerkzeuge und können dieselbe Adresse direkt mit Guzzle aufrufen; da das Ergebnis bereits strukturierte Daten sind, entfällt auch der Parser-Schritt. Wie Sie erkennen, ob eine Seite dynamisch ist, zeigen wir Schritt für Schritt in [Statische und dynamische Seiten](/de/blog/static-vs-dynamic-pages).

## Einsatzbereiche

- **Lieferantenpreise ins eigene Panel holen:** Eine nachts laufende Cron-Aufgabe durchläuft die Produktliste und aktualisiert Preis- und Bestandsfelder; der Aufbau steht auf unserer Seite zum [Data Scraping](/de/data-scraping).
- **Wettbewerberpreise verfolgen:** Den Preis desselben Produkts auf mehreren Websites täglich festhalten; beschrieben in [Preisüberwachung der Wettbewerber](/de/blog/competitor-price-tracking) und auf unserer Seite zur [Preisüberwachung](/de/price-monitoring).
- **Die eigene Website durchsuchen:** Die eigene Domain nach defekten Links und fehlenden Titeln durchgehen; siehe unsere Seite zum [Web Crawler](/de/web-crawler).
- **Eigene Marktplatzangebote prüfen:** Kontrollieren, ob Bestands- und Titelfelder mit Ihrem Panel übereinstimmen; siehe unsere Seite zu [E-Commerce-Lösungen](/de/e-commerce-proxy).
- **Öffentliche Tabellen auslesen:** Wechselkurs-, Tarif- oder Anzeigentabellen von Behördenseiten übernehmen; eine sprachübergreifende Zusammenfassung der Methode steht in [Daten von einer Website abrufen](/de/blog/extract-data-from-website).

## Häufige Fehler

- **Ein `200` ohne Blick auf den Inhalt akzeptieren.** Auch Prüf- und Fehlerseiten liefern `200`; kontrollieren Sie vor jedem Parsen, ob ein erwartetes Element vorhanden ist.
- **Kein Zeitlimit setzen.** Eine einzige nicht antwortende Seite hält das Skript wegen `default_socket_timeout` eine Minute auf. Geben Sie beide Zeitlimits ausdrücklich an.
- **Den Preis als Text speichern.** Halten Sie die Zeichenkette `£51.77` unverändert fest, können Sie weder vergleichen noch summieren. Wandeln Sie sie in eine Zahl um und legen Sie die Währung in eine eigene Spalte.
- **Den Klassennamen mit Gleichheit suchen.** Ein XPath mit `@class="product_pod"` findet ein Tag mit `class="product_pod col-xs-6"` nicht.
- **Keinen Port in die Proxy-Adresse schreiben.** libcurl versucht standardmäßig 1080, und die Fehlermeldung führt Sie in die Irre.
- **Den Fehler `407` auf der Zielseite suchen.** Dieser Code kommt vom Proxy; prüfen Sie Benutzername, Passwort und Whitelist.
- **Die Gleichzeitigkeit gierig einstellen.** Zwanzig parallele Anfragen beschleunigen die Arbeit nicht, sie treiben Sie ins Ratenlimit.
- **Zugangsdaten in den Code schreiben.** Benutzername und Passwort des Proxys gehören nicht in die Versionsverwaltung.

## Entscheidungshilfe

| Bedarf | Empfehlung |
|---|---|
| Ein paar Felder aus einer Seite | `curl_*` + `DOMDocument` und XPath |
| PHP 8.4 und Gewohnheit für CSS-Selektoren | `querySelectorAll` mit `Dom\HTMLDocument` |
| Regelmäßige Arbeit über Dutzende Seiten | Guzzle + DomCrawler mit Wiederholungs-Middleware |
| Zwischen Listenseiten wandern | DomCrawler `link()->getUri()` für absolute Adressen |
| Viele Anfragen von einer IP, Sie bekommen `429` | Tempo senken, dann [Rotierender Proxy](https://proxynet.io/de/rotating-proxy) |
| Rechenzentrumsadressen werden eingeschränkt | [Residential-Proxy](https://proxynet.io/de/residential-proxy) |
| Während einer Sitzung wird dieselbe Adresse gebraucht | [Sticky-Proxy](https://proxynet.io/de/sticky-proxy) |
| Der Inhalt kommt per JavaScript | Zuerst nach der JSON-Anfrage im Hintergrund suchen |
| Die Website bietet eine offizielle API | Die API statt Scraping |

## Häufige Fragen

### Ist PHP eine geeignete Sprache für Web Scraping?

Ja, wenn Sie die Grenze kennen. Bei HTTP-Anfrage und HTML-Parsing sind die Werkzeuge ausgereift: Die cURL-Erweiterung öffnet libcurl vollständig, Guzzle liefert Gleichzeitigkeit und Wiederholungen, DomCrawler mit XPath gibt Ihnen starke Selektoren. Schwach ist PHP bei der Browserautomatisierung. Wenn Sie Daten in ein System einspeisen, das ohnehin in PHP geschrieben ist, ist es einfacher, die Arbeit in PHP zu halten, als die Daten aus einer zweiten Sprache herüberzutragen.

### Soll ich die Bibliothek Simple HTML DOM verwenden?

Diese Bibliothek, die in vielen älteren Anleitungen auftaucht, wird seit Langem nicht mehr gepflegt und arbeitet bei großen Seiten spürbar langsam. Dieselbe Aufgabe erledigt das im Kern von PHP enthaltene `DOMDocument` ohne Abhängigkeit, wird in PHP 8.4 mit `Dom\HTMLDocument` HTML5-fähig, und wenn Sie eine jQuery-ähnliche Schnittstelle möchten, gibt es Symfony DomCrawler. Für ein neues Projekt wählen Sie eines dieser drei.

### Was ist der Unterschied zwischen cURL und Guzzle?

Guzzle nutzt im Hintergrund ohnehin cURL; der Unterschied liegt in der Abstraktionsebene. Rufen Sie eine einzelne Seite ab, genügen die `curl_*`-Funktionen, und Sie müssen kein Paket installieren. Wollen Sie Wiederholungen, einen Anfragepool, Middleware und nach Statuscode getrennte Ausnahmen, nehmen Sie Guzzle. Die Proxy-Einstellung sind in beiden Fällen ein paar Zeilen.

### Ich bekomme beim Proxy-Einsatz den Fehler 407, was soll ich tun?

`407` kommt nicht von der Zielseite, sondern vom Proxy, und besagt, dass die Authentifizierung fehlgeschlagen ist. Prüfen Sie zuerst Benutzername und Passwort. Enthält das Passwort `@` oder `:`, muss es innerhalb der Adresse kodiert sein; die getrennte Option `CURLOPT_PROXYUSERPWD` beseitigt dieses Problem. Nutzen Sie die IP-Whitelist, bestätigen Sie, dass die Ausgangsadresse Ihres Servers auf der Liste steht. Und denken Sie daran, dass `407` bei HTTPS-Anfragen in `CURLINFO_HTTP_CONNECTCODE` statt in `CURLINFO_RESPONSE_CODE` erscheint.

### Auf der abgerufenen Seite kommen Sonderzeichen beschädigt an, warum?

Höchstwahrscheinlich nutzen Sie `DOMDocument::loadHTML`, und die Seite hat kein `<meta charset>`-Tag. Dann behandelt der Parser den Inhalt nicht als UTF-8. Die Lösung ist entweder, dem HTML `<?xml encoding="UTF-8">` voranzustellen, oder auf die Methode `Dom\HTMLDocument::createFromString` aus PHP 8.4 zu wechseln; diese erkennt die Kodierung selbst korrekt. Auch eine komprimiert eintreffende Antwort kann ähnlichen Schaden anrichten, geben Sie dafür `CURLOPT_ENCODING` eine leere Zeichenkette mit.

### In welchem Abstand soll ich Anfragen senden?

Eine feste Zahl gibt es nicht, aber zwei Maßstäbe helfen: die Größe des Ziels (eine kleine Behördenseite und ein großer Marktplatz tragen nicht dieselbe Last) und die Reaktion der Website selbst (bekommen Sie `429`, sind Sie zu schnell und müssen sich an den Wert von `Retry-After` halten). Ein praktischer Anfang ist eine Sekunde zwischen den Anfragen bei einer Gleichzeitigkeit von höchstens zwei.

## Fazit

Scraping mit PHP besteht darin, die Anfrage mit `curl_*` zu senden und das eingegangene HTML mit `DOMDocument` + XPath oder mit DomCrawler zu parsen. `file_get_contents` und reguläre Ausdrücke brechen auf der ersten echten Seite, weil sie weder Statuscode noch Tag-Varianten sehen. Wächst die Arbeit über ein paar Seiten hinaus, kommen die Wiederholungs-Middleware und der Anfragepool von Guzzle ins Spiel. Auf der Proxy-Seite genügen `CURLOPT_PROXY` und `CURLOPT_PROXYUSERPWD`; denken Sie daran, den Port anzugeben und den Fehler `407` auf der Proxy-Seite zu suchen. Entscheidend sind aber die drei Festlegungen vor dem Code: robots.txt beachten, zwischen den Anfragen warten und nur Daten mit Tatsachencharakter abrufen. Die passenden Proxy-Typen finden Sie in unseren [Proxy-Diensten](/de/proxy).
