Import danych poprzez scraping dynamicznych stron w PHP z użyciem Symfony Panther

Import danych poprzez scraping dynamicznych stron w PHP z użyciem Symfony Panther

W świecie e-commerce często musimy pobierać dane z zewnętrznych źródeł — czy to do integracji z katalogami producentów, aktualizacji stanów magazynowych, czy automatycznego tworzenia kategorii produktów.

Z pozoru proste zadanie, jakim jest odczytanie listy elementów z danej strony, potrafi jednak okazać się zaskakująco trudne, gdy w grę wchodzi nowoczesna strona oparta o JavaScript.

Klasyczne biblioteki PHP, takie jak Simple HTML DOM, Goutte czy nawet dobrze znany cURL, działają świetnie, dopóki zawartość strony jest generowana po stronie serwera. Wystarczy pobrać kod HTML, przetworzyć go i wyodrębnić potrzebne dane. Problem zaczyna się w momencie, gdy witryna – tak jak coraz więcej współczesnych serwisów – renderuje zawartość dopiero po stronie przeglądarki, przy użyciu frameworków takich jak Angular, React czy Vue.js. Wtedy w źródle HTML nie znajdziemy już listy produktów, kategorii czy roczników — zamiast tego zobaczymy tylko kilka pustych kontenerów z atrybutami ng-repeat lub data-v-*.

Na co dzień w Design Cart pracujemy głównie z prostymi parserami. Wystarczają one w 95% przypadków, gdy dane są statyczne lub gdy da się je pobrać przez API producenta. Tym razem jednak trafiliśmy na stronę, która generowała całą strukturę katalogu dopiero po wykonaniu kodu AngularJS. Standardowe metody zawiodły, a wynikiem każdego testu był pusty HTML. Takie przypadki pokazują, że skuteczne integracje i automatyzacje są dziś nieodłącznym elementem Tworzenia sklepów internetowych — szczególnie wtedy, gdy pracujemy z dynamicznymi źródłami danych i nowoczesnymi technologiami frontendowymi.

Z tego powodu postanowiliśmy przeprowadzić mały eksperyment. Celem było stworzenie automatycznego importera kategorii dla projektu opartego na OpenCart, który potrafiłby „zobaczyć” to samo, co realny użytkownik odwiedzający stronę. Jako środowisko testowe posłużyła domena skanowana-domena.pl, na której przetestowaliśmy kilka podejść – aż w końcu znaleźliśmy rozwiązanie, które naprawdę działa.

Może Cię też zainteresować: Importowanie produktów do sklepu internetowego

 

Typowe podejścia, które nie działają

Zanim sięgnęliśmy po Symfony Panther, przetestowaliśmy kilka klasycznych metod, które w innych projektach sprawdzają się znakomicie. Każda z nich jednak miała tę samą fundamentalną wadę – brak obsługi JavaScriptu.

➡ Simple HTML DOM
To nasz podstawowy wybór w codziennej pracy — lekki, szybki i prosty w użyciu. Wystarczy jedno file_get_html() i możemy przeszukiwać strukturę dokumentu jak w jQuery. Problem w tym, że biblioteka pobiera tylko surowy kod HTML z serwera. Jeśli strona generuje treść dynamicznie (np. przez AngularJS), parser zobaczy jedynie puste kontenery i atrybuty ng-repeat, bez faktycznych danych.

➡ Goutte / DOMCrawler
Narzędzia oparte na komponentach Symfony, idealne do scrapingu klasycznych stron. Dają większe możliwości niż Simple HTML DOM, np. symulowanie kliknięć, formularzy czy nagłówków HTTP. Jednak tak samo jak poprzednie rozwiązanie — nie potrafią uruchomić JavaScriptu, więc w przypadku aplikacji jednostronicowych (SPA) są bezużyteczne.

➡ Puppeteer (Node.js)
Tu wszystko działa, ale… nie w PHP. Puppeteer uruchamia prawdziwą przeglądarkę Chrome w trybie headless, renderuje stronę, wykonuje skrypty JS i pozwala pobrać gotowy DOM. To podejście jest skuteczne, lecz wymaga środowiska Node.js, konfiguracji osobnego procesu i komunikacji między PHP a Node. Dla prostego importera w OpenCart byłoby to rozwiązanie zbyt ciężkie i trudne w utrzymaniu.

💡 Wniosek:
Żadne z typowych rozwiązań PHP nie sprawdza się, gdy zawartość strony jest renderowana dopiero po stronie przeglądarki. W takich sytuacjach potrzebne jest narzędzie, które potrafi faktycznie „uruchomić” stronę — a nie tylko pobrać jej kod źródłowy.
I tu właśnie do gry wchodzi Symfony Panther.

 

Symfony Panther – headless Chrome w PHP

Symfony Panther to jedno z najbardziej niedocenianych, a zarazem najpotężniejszych narzędzi w świecie PHP. Stworzony przez zespół Symfony, Panther pozwala uruchamiać prawdziwą przeglądarkę Chrome lub Chromium w trybie headless (bez interfejsu graficznego), wykorzystując protokół WebDriver.
W praktyce oznacza to, że możemy z poziomu PHP robić dokładnie to samo, co użytkownik przeglądarki — wczytywać strony, klikać linki, wypełniać formularze, czekać na załadowanie treści i pobierać dane już po wykonaniu JavaScriptu.

 

🔧 Jak działa Panther

Panther opiera się na Facebook WebDriver – standardzie automatyzacji przeglądarek, który komunikuje się z ChromeDriverem (lub GeckoDriverem w przypadku Firefoksa). Gdy w kodzie PHP wywołujemy np. $client->request('GET', $url), Panther naprawdę otwiera stronę w silniku Chrome, uruchamia wszystkie skrypty JS i dopiero po ich wykonaniu udostępnia gotowy DOM.

 

💡 Co potrafi:

  • Renderowanie JavaScriptu w czasie rzeczywistym – nie interesuje nas, czy strona korzysta z Angulara, Reacta czy Vue. Panther widzi to, co widzi użytkownik.

  • Obsługa dynamicznego DOM – elementy dodawane przez JS (np. przez ng-repeat lub fetch()) są normalnie dostępne w kodzie PHP.

  • Inteligentne oczekiwanie – metoda $client->waitFor('.selector', 10) pozwala zatrzymać skrypt do momentu, aż określony element pojawi się na stronie. To kluczowa funkcja przy analizie witryn, które ładują dane asynchronicznie.

 

🧩 Dlaczego wybraliśmy Panthera

Podczas tworzenia automatycznego importera kategorii z skanowana-domena.pl potrzebowaliśmy sposobu, by w PHP wczytać strukturę strony generowaną przez AngularJS.
Alternatywy, takie jak Puppeteer, wymagałyby uruchamiania osobnych procesów Node.js i tworzenia warstwy komunikacji z PHP. Panther działa natywnie w PHP, bez dodatkowych serwerów, dzięki czemu świetnie integruje się z frameworkami i systemami e-commerce – w tym z OpenCartem.

To rozwiązanie dało nam pełną kontrolę nad procesem — od załadowania strony po zapis danych w bazie — bez wychodzenia z ekosystemu PHP.

 

Nasz przypadek – importer kategorii

W tym projekcie musieliśmy zbudować trójpoziomowy importer kategorii z witryny skanowana-domena.pl. Struktura wyglądała tak:

  • Modele → Serie → Roczniki (silniki)

  • Każdy poziom pojawiał się dopiero po wyrenderowaniu przez AngularJS, więc klasyczny parser HTML nic tu nie widział.

 

Co dokładnie zrobiliśmy (krok po kroku)

  1. Wejście na stronę główną katalogu
    Uruchamiamy przeglądarkę w trybie headless i ładujemy adres katalogu marki.
    Następnie czekamy na pojawienie się listy modeli:

    • Selektor: .catalog-list-1 a

    • Dzięki waitFor() skrypt rusza dalej dopiero, gdy elementy faktycznie są w DOM-ie.

  2. Pobranie listy modeli
    Z poziomu listy zbieramy pary (nazwa, href) każdego modelu.

    • Przykładowe selektory: .catalog-list-1 a

    • Czyścimy nazwy (trim), zapisujemy linki względne jako absolutne.

  3. Wejście w serie dla każdego modelu
    Dla każdego modelu wykonujemy żądanie GET i znów czekamy, aż Angular doładuje sekcję z seriami:

    • Selektory: .catalog-list-1 a, .catalog-list-1-span a

    • Zbieramy listę serii (nazwa + href).

  4. Pobranie roczników (silników) z poziomu serii
    Każdą serię otwieramy osobno i czekamy na kontener roczników:

    • Główny kontener: #motor-container

    • Pojedyncze wiersze: #motor-container .row.border-b

    • Z wiersza odczytujemy:

      • typ/silnik: element strong,

      • zakres lat: element .pull-right,

      • href: atrybut href z <a>.
        Z tych trzech pól budujemy etykietę rocznika, np. 650 [2011–2011].

  5. Budowa drzewa w pamięci
    Na podstawie powyższych kroków składamy strukturę:

     
    Model └── Seria └── Rocznik (typ + lata)

    Dopiero kompletne drzewo przekazujemy dalej do warstwy zapisu (u nas: kategorie OpenCart).

 

Dlaczego to działa

  • Każdy etap jest blokowany do czasu, aż dynamiczne elementy pojawią się w DOM-ie (metoda waitFor()).

  • Parsujemy rzeczywiście wyrenderowany HTML, a nie surowy szablon przed uruchomieniem JS.

  • Dzięki temu Angularowe listy (ng-repeat) są już materializowane i gotowe do odczytu.

 

Kod źródłowy importera (PHP + Panther)

Cały importer został napisany jako kontroler OpenCart w pliku:
catalog/controller/product/dc_import_remote_categories.php.

To nie jest osobny skrypt — działa bezpośrednio w środowisku sklepu, co pozwala od razu zapisywać kategorie do bazy i generować aliasy SEO.

 

Co robi ten kod (w skrócie)

  1. Inicjalizacja klienta przeglądarki

     
    $client = Client::createChromeClient(null, ['--headless', '--no-sandbox', '--disable-gpu']);

    Otwiera instancję headless Chrome, która pozwala na renderowanie JavaScript i interakcję z dynamicznym DOM-em.

  2. Oczekiwanie na elementy (waitFor)

     
    $client->waitFor('.catalog-list-1 a', 15);

    Zatrzymuje dalsze wykonanie do momentu, aż wskazane elementy pojawią się w strukturze strony.
    Dzięki temu importer nie analizuje pustego szablonu Angulara, tylko już wyrenderowany HTML.

  3. Iteracja po linkach i budowa struktury $tree

    • Na pierwszym poziomie pobiera modele (linki .catalog-list-1 a).

    • Dla każdego modelu pobiera serie (linki .catalog-list-1, .catalog-list-1-span).

    • Następnie wchodzi w stronę serii, pobiera roczniki z #motor-container i buduje trójpoziomowe drzewo:

       
      [ 'name' => 'Model', 'children' => [ ['name' => 'Seria', 'children' => [ ['name' => 'Rocznik'] ]] ] ]
  4. Zapis do bazy danych

    • Każdy węzeł drzewa to osobna kategoria (ensureCategory()).

    • Alias SEO generowany jest automatycznie przez ensureSeoAlias():

      • Tworzy unikalny keyword na podstawie nazwy i ID kategorii.

      • Sprawdza kolizje, by uniknąć duplikatów w tabeli seo_url.

  5. Efekt końcowy

    • Pełne, trójpoziomowe drzewo kategorii utworzone w bazie.

    • Każda kategoria ma własny alias SEO, np.:

       
      /c-400-gt-x /f800-r /650-2012-2012

W dalszej części artykułu wkleimy pełny kod kontrolera z komentarzami i zachowaną strukturą — gotowy do uruchomienia w każdym sklepie OpenCart z zainstalowanym Symfony Panther.

 

<?php
    namespace Opencart\Catalog\Controller\Product;
    use Symfony\Component\Panther\Client;
    use Symfony\Component\Panther\DomCrawler\Crawler;

    class DcImportRemoteCategories extends \Opencart\System\Engine\Controller {

        public $scan_url = 'https://www.skanowana-domena.pl';

        public function index(): void {
            if (($this->request->get['mode'] ?? '') === 'remus-panther') {
                require_once DIR_SYSTEM . '../vendor/autoload.php';

                echo "Start importu (Remus Panther)\n";
                $root_id = (int)($this->request->get['root_id'] ?? 0);
                $url = $this->request->get['url'] ?? $this->scan_url.'/katalog/moto-bmw/2;73';
                $test = (int)($this->request->get['test'] ?? 1);

                if (!$root_id) {
                    echo "❌ Brak parent_id\n";
                    return;
                }

                echo "🌍 Ładowanie: {$url}\n";
                $client = Client::createChromeClient(null, ['--headless', '--no-sandbox', '--disable-gpu']);
                $crawler = $client->request('GET', $url);
                $client->waitFor('.catalog-list-1 a', 15);

                $links = $crawler->filter('.catalog-list-1 a')->each(fn(Crawler $node) => [
                    'name' => trim($node->text()),
                    'href' => $node->attr('href')
                ]);

                echo "✅ Znaleziono " . count($links) . " modeli\n";

                $tree = [];

                foreach ($links as $link) {
                    $model_name = $link['name'];
                    $model_url = $this->scan_url . $link['href'];
                    echo "MODEL: {$model_name}\n";

                    try {
                        $crawler = $client->request('GET', $model_url);
                        $client->waitFor('.catalog-list-1 a, .catalog-list-1-span a, .thead', 15);
                    } catch (\Throwable $e) {
                        echo "  ⚠️ Nie udało się wczytać modelu: {$model_name} ({$model_url})\n";
                        continue;
                    }

                    $series_links = $crawler->filter('.catalog-list-1 a, .catalog-list-1-span a')->each(fn(Crawler $node) => [
                        'name' => trim($node->text()),
                        'href' => $node->attr('href')
                    ]);

                    $series_nodes = [];
                    foreach ($series_links as $slink) {
                        $series_name = $slink['name'];
                        $series_url = $this->scan_url . $slink['href'];
                        echo "  SERIA: {$series_name}\n";

                        // 🔹 Wejście w stronę serii
                        $crawler = $client->request('GET', $series_url);

                        try {
                            // czekamy na kontener motorów (silników / roczników)
                            $client->waitFor('#motor-container .row.border-b', 7);
                        } catch (\Throwable $e) {
                            echo "    ⚠️ Brak sekcji #motor-container w {$series_name}\n";
                            continue;
                        }

                        $types_nodes = [];
                        $motors = $crawler->filter('#motor-container .row.border-b');

                        foreach ($motors as $motor) {
                            try {
                                $typeEl = $motor->findElement(\Facebook\WebDriver\WebDriverBy::cssSelector('strong'));
                                $yearsEl = $motor->findElement(\Facebook\WebDriver\WebDriverBy::cssSelector('.pull-right'));
                                $linkEl = $motor->findElement(\Facebook\WebDriver\WebDriverBy::cssSelector('a'));

                                $type = trim($typeEl->getText());
                                $years = trim($yearsEl->getText());
                                $href = $linkEl->getAttribute('href');

                                if ($type) {
                                    $label = $type . ($years ? " [{$years}]" : '');
                                    echo "    📅 {$label}\n";
                                    $types_nodes[] = [
                                        'name' => $label,
                                        'href' => $href
                                    ];
                                }
                            } catch (\Throwable $e) {
                                // pomiń wiersz, jeśli brakuje któregoś elementu
                                continue;
                            }
                        }

                        $series_nodes[] = [
                            'name' => $series_name,
                            'children' => $types_nodes
                        ];
                    }


                    $tree[] = [
                        'name' => $model_name,
                        'children' => $series_nodes
                    ];
                }

                echo "\n— PODGLĄD DRZEWA —\n";
                $this->printTree($tree);

                if (!$test) {
                    $this->saveTree($tree, $root_id);
                    echo "\n✅ Zapisano kategorie do bazy\n";
                }

                echo "\n🧹 Zamykanie Chrome...\n";
                $client->quit();

                return;
            }

        }

        private function printTree(array $tree, int $level = 0): void {
            if ($level === 0) {
                echo "<!DOCTYPE html><html lang='pl'><head><meta charset='utf-8'><title>Podgląd drzewa kategorii</title>
                <style>
                    body { font-family: Arial, sans-serif; background: #111; color: #eee; padding: 20px; }
                    ul { list-style: none; margin-left: 20px; padding-left: 0; }
                    li { margin: 4px 0; padding: 4px 8px; background: #1e1e1e; border-radius: 6px; }
                    li:hover { background: #2a2a2a; }
                    .level-0 { color: #ffcc00; font-weight: bold; font-size: 18px; }
                    .level-1 { color: #6cf; }
                    .level-2 { color: #8f8; }
                    .level-3 { color: #ccc; }
                </style>
                </head><body><h2>📦 Podgląd drzewa Remus</h2><ul>";
            }

            foreach ($tree as $node) {
                $class = "level-" . $level;
                echo "<li class='{$class}'>" . htmlspecialchars($node['name']);

                if (!empty($node['children'])) {
                    echo "<ul>";
                    $this->printTree($node['children'], $level + 1);
                    echo "</ul>";
                }

                echo "</li>";
            }

            if ($level === 0) {
                echo "</ul></body></html>";
            }
        }

        private function saveTree(array $tree, int $parent_id): void {
            $this->load->model('catalog/category');
            $lang_id = (int)$this->config->get('config_language_id') ?: 1;

            foreach ($tree as $model) {
                $model_id = $this->ensureCategory($parent_id, $model['name'], $lang_id);

                foreach ($model['children'] as $series) {
                    $series_id = $this->ensureCategory($model_id, $series['name'], $lang_id);

                    foreach ($series['children'] as $leaf) {
                        $leaf_id = $this->ensureCategory($series_id, $leaf['name'], $lang_id);
                        $this->ensureSeoAlias($leaf_id, $leaf['name']);
                    }

                    $this->ensureSeoAlias($series_id, $series['name']);
                }

                $this->ensureSeoAlias($model_id, $model['name']);
            }
        }

        private function ensureCategory(int $parent_id, string $name, int $language_id): int {
            $q = $this->db->query("SELECT c.category_id 
                FROM " . DB_PREFIX . "category c 
                JOIN " . DB_PREFIX . "category_description cd 
                ON (c.category_id = cd.category_id AND cd.language_id = " . (int)$language_id . ")
                WHERE c.parent_id = " . (int)$parent_id . " 
                AND cd.name = '" . $this->db->escape($name) . "' 
                LIMIT 1");

            if ($q->num_rows) {
                return (int)$q->row['category_id'];
            }

            $data = [
                'parent_id' => $parent_id,
                'filters' => [],
                'column' => 1,
                'sort_order' => 0,
                'status' => 1,
                'category_store' => [0],
                'category_layout' => [],
                'image' => '',
                'top' => 0,
                'category_description' => [
                    $language_id => [
                        'name' => $name,
                        'meta_title' => $name
                    ]
                ]
            ];

            return $this->addCategoryRaw($data, $language_id);
        }


        private function generateSeoKeyword(string $name, int $id): string {
            // usuń znaki specjalne, polskie litery, spacje -> myślniki
            $keyword = strtolower(trim(preg_replace('/[^a-zA-Z0-9]+/', '-', html_entity_decode($name, ENT_QUOTES, 'UTF-8'))));
            $keyword = trim($keyword, '-');

            // sprawdź, czy alias istnieje — jeśli tak, dodaj ID
            $q = $this->db->query("SELECT * FROM " . DB_PREFIX . "seo_url WHERE keyword = '" . $this->db->escape($keyword) . "' LIMIT 1");
            if ($q->num_rows) {
                $keyword .= '-' . $id;
            }

            return $keyword;
        }

        private function ensureSeoAlias(int $category_id, string $name): void {
            $language_id = (int)$this->config->get('config_language_id');
            $store_id = 0;

            // Najpierw sprawdzamy, czy alias już istnieje dla tej kategorii
            $q = $this->db->query("SELECT seo_url_id FROM " . DB_PREFIX . "seo_url 
                WHERE `key` = 'category_id' 
                AND `value` = '" . (int)$category_id . "'
                AND store_id = " . (int)$store_id . "
                AND language_id = " . (int)$language_id . "
                LIMIT 1");

            $keyword = $this->generateSeoKeyword($name, $category_id);

            if ($q->num_rows) {
                // Aktualizujemy keyword
                $this->db->query("UPDATE " . DB_PREFIX . "seo_url 
                    SET keyword = '" . $this->db->escape($keyword) . "' 
                    WHERE seo_url_id = " . (int)$q->row['seo_url_id']);
            } else {
                // Wstawiamy nowy alias
                $this->db->query("INSERT INTO " . DB_PREFIX . "seo_url 
                    SET store_id = " . (int)$store_id . ",
                        language_id = " . (int)$language_id . ",
                        `key` = 'category_id',
                        `value` = '" . (int)$category_id . "',
                        keyword = '" . $this->db->escape($keyword) . "'");
            }

            echo "  🔗 alias SEO: {$keyword}\n";
        }

        private function addCategoryRaw(array $data, int $language_id): int {
            // Wstawienie głównej kategorii
            $this->db->query("INSERT INTO " . DB_PREFIX . "category SET 
                parent_id = '" . (int)$data['parent_id'] . "',
                sort_order = '" . (int)$data['sort_order'] . "',
                status = '" . (int)$data['status'] . "'");

            $category_id = $this->db->getLastId();

            // Opis kategorii
            $this->db->query("INSERT INTO " . DB_PREFIX . "category_description SET 
                category_id = '" . (int)$category_id . "',
                language_id = '" . (int)$language_id . "',
                name = '" . $this->db->escape($data['category_description'][$language_id]['name']) . "',
                description = '',
                meta_title = '" . $this->db->escape($data['category_description'][$language_id]['meta_title']) . "',
                meta_description = '',
                meta_keyword = ''");

            // Przypisanie do sklepu
            $this->db->query("INSERT INTO " . DB_PREFIX . "category_to_store SET 
                category_id = '" . (int)$category_id . "',
                store_id = 0");

            // Path (dla hierarchii)
            $level = 0;
            $query = $this->db->query("SELECT * FROM " . DB_PREFIX . "category_path WHERE category_id = '" . (int)$data['parent_id'] . "' ORDER BY level ASC");

            foreach ($query->rows as $result) {
                $this->db->query("INSERT INTO " . DB_PREFIX . "category_path SET 
                    category_id = '" . (int)$category_id . "',
                    path_id = '" . (int)$result['path_id'] . "',
                    level = '" . (int)$level . "'");
                $level++;
            }

            $this->db->query("INSERT INTO " . DB_PREFIX . "category_path SET 
                category_id = '" . (int)$category_id . "',
                path_id = '" . (int)$category_id . "',
                level = '" . (int)$level . "'");

            echo "  + utworzono kategorię: {$data['category_description'][$language_id]['name']} (ID: {$category_id}, parent: {$data['parent_id']})\n";
            return $category_id;
        }

    }

 

Wyzwania i pułapki

Praca z Symfony Panther to spory krok naprzód względem klasycznych parserów PHP, ale wymaga kilku technicznych przygotowań i świadomości, że nie wszystko pójdzie gładko od razu.

 

🧩 Wymagania systemowe

Aby Panther działał, potrzebny jest:

  • Google Chrome lub Chromium – przeglądarka, która faktycznie renderuje kod JavaScript,

  • chromedriver – sterownik umożliwiający zdalne sterowanie przeglądarką,

  • oraz rozszerzenie PHP Panther (instalowane przez Composer).

W praktyce oznacza to, że serwer, na którym uruchamiamy importer, musi mieć zainstalowane i działające środowisko Chrome + chromedriver. Bez tego pojawi się komunikat:
fsockopen(): Unable to connect to 127.0.0.1:9515 (Connection refused).

 

⚠️ Typowe błędy

  1. Connection refused – brak aktywnego procesu chromedriver.
    ➜ Pomaga ponowne uruchomienie ChromeDrivera lub reinstalacja Panthera.

  2. Element not found – wynik działania waitFor() nie został znaleziony w zadanym czasie.
    ➜ Należy zwiększyć limit czasu oczekiwania lub zweryfikować, czy strona nie zmieniła struktury DOM.

  3. Problemy z pamięcią – uruchomienie wielu instancji headless Chrome’a jednocześnie może zużywać dużo RAM.
    ➜ Zalecane jest uruchamianie importu sekwencyjnie (jedna instancja na raz).

 

🧱 Różnice między wersjami OpenCart

Podczas integracji z OpenCart natrafiliśmy na niespodziewany błąd SQL:
Unknown column 'top' in 'INSERT INTO'.

Okazało się, że starsze wersje OpenCart (np. 2.x i wczesne 3.x) nie posiadają kolumny top w tabeli category.
Importer został więc dostosowany, by działał również na tych wersjach – wystarczy pominąć nieistniejące kolumny.

 

🔗 Alias SEO – unikalność gwarantowana

OpenCart wymaga unikalnych aliasów (keyword) w tabeli seo_url.
Ponieważ importer tworzył wiele kategorii o podobnych nazwach (np. “650 2012–2012”), konieczne było dodanie zabezpieczenia:

 
if ($existing_alias) { $keyword .= '-' . $id; }

Dzięki temu nawet przy powtórzeniach nazw każda kategoria otrzymuje unikalny, stabilny alias w formacie:

 
/800-2015-2015-82 /c-400-gt-x-75 /f900-xr-2020-2020-101

To rozwiązanie całkowicie wyeliminowało konflikty SEO i umożliwiło poprawne tworzenie drzew kategorii nawet w setkach pozycji.

 

Parametry środowiska testowego

Importer działał w środowisku lokalnym na systemie Linux:

 
System: Ubuntu 24.04 LTS (x86_64) PHP: 8.3 (CLI + FPM) Web server: Apache 2.4 Baza danych: MariaDB 10.6 Przeglądarka: Google Chrome 141.0.7390.122 (headless) Sterownik: chromedriver 141.0.7390.0 Symfony Panther: v2.2.0 OpenCart: 3.0.x (modyfikacja Design Cart)

Importer był uruchamiany poleceniem:

 
php /var/www/html/md/index.php?route=product/dc_import_remote_categories&mode=remus-panther&root_id=60&url=https://skanowana-domena.pl/katalog/moto-bmw/2;73&test=0

💡 Ważne:
Aby zapewnić stabilność działania, Chrome uruchamiany był w trybie:

 
--headless --no-sandbox --disable-gpu

co pozwala uniknąć błędów uprawnień w środowisku serwerowym.

 

Wynik i wnioski

Efekt testu był w pełni satysfakcjonujący — importer poprawnie zaczytał pełne drzewo kategorii ze strony typu SPA (renderowanej w AngularJS), obejmujące trzy poziomy: modele, serie i roczniki. Wszystkie dane zostały zapisane bezpośrednio w bazie OpenCart, wraz z automatycznie wygenerowanymi aliasami SEO.

Symfony Panther okazał się realnym i stabilnym rozwiązaniem dla projektów, w których klasyczne parsery HTML zawodzą. Dzięki możliwości renderowania JavaScriptu oraz funkcji waitFor() mogliśmy w sposób w pełni kontrolowany czekać na załadowanie danych w DOM — co w przypadku klasycznych bibliotek byłoby niemożliwe.

💡 Wskazówki praktyczne:

  • Panther doskonale sprawdza się przy jednorazowych integracjach, testach, czy analizach dynamicznych stron (np. katalogów produktów, konfiguratorów, SPA).

  • Przy większych projektach scrapujących warto rozważyć łączenie Panthara z systemami kolejkowania (np. RabbitMQ, Redis Queue) lub mechanizmami cache, aby unikać ponownego renderowania tych samych podstron.

  • Dobrą praktyką jest również łączenie Panthara z własnym modułem logowania — pozwala to monitorować błędy (Connection refused, Element not found) i wznawiać proces od ostatniego poprawnego węzła drzewa.

W praktyce Symfony Panther pozwolił przenieść cały proces scrapingu do czystego środowiska PHP, bez potrzeby uruchamiania Node.js czy zewnętrznych serwisów Selenium — co znacząco uprościło integrację z OpenCart i zmniejszyło ryzyko błędów w środowisku produkcyjnym.

 

Podsumowanie

Eksperyment z Symfony Panther pokazał, jak wiele można osiągnąć, gdy klasyczne narzędzia przestają wystarczać.
Dowiedzieliśmy się, kiedy Simple HTML DOM nie poradzi sobie z dynamiczną zawartością, jak Panther umożliwia „zobaczenie strony oczami użytkownika” — czyli pełne renderowanie DOM po stronie przeglądarki — oraz w jaki sposób można połączyć go z OpenCart, by automatycznie tworzyć strukturę kategorii w bazie danych.

To rozwiązanie pozwoliło nam przejść z prostych parserów HTML do rzeczywistej analizy stron typu SPA (Single Page Application), bez opuszczania ekosystemu PHP.

Jeśli tworzysz integracje, importery lub systemy analizy danych — spróbuj własnych testów z Pantherem.
A jeśli uzyskasz ciekawe rezultaty, podziel się nimi w naszym Laboratorium Design Cart — chętnie opublikujemy wyniki Twoich eksperymentów i wspólnie poszerzymy granice możliwości PHP w praktycznych zastosowaniach.