CMS y plataformas 17 min de lectura

Scraper e importador para OpenCart: versiones, soluciones listas y conversión de divisas

Scraper para OpenCart: diferencias entre versiones, módulos listos para poblar el catálogo y un ejemplo de importación de productos con conversión de precios a la divisa de la tienda.

EW
Equipo Web-Scraping.es
Recopilación de datos para las necesidades del negocio
Publicado: 20 febrero 2025

Poblar una tienda online a mano es la parte más cara y tediosa del lanzamiento. Cuando el proveedor maneja miles de referencias y los precios cambian cada semana, no hay forma de arreglárselas sin automatización. Aquí entra en escena el scraper para OpenCart: un programa que recopila los datos de productos de un sitio de origen (proveedor, distribuidor, marketplace) y los vuelca en su tienda con nombres, descripciones, fotografías, características y, lo que es especialmente importante, con los precios correctamente convertidos.

En este artículo veremos en qué se diferencian las versiones de OpenCart desde el punto de vista del scraping, qué soluciones listas existen y escribiremos nuestro propio scraper en Python que recopila productos del sitio del proveedor y convierte la divisa en paralelo.


Por qué la versión de OpenCart importa

Al final, el scraper escribe los datos en la base o genera un archivo de importación, y la estructura de la base y la lógica de las URL SEO varían entre versiones de OpenCart. Ignorarlo es la garantía de acabar con productos «rotos» o errores de importación.

OpenCart apareció allá por 2005 y desde entonces ha pasado por varias reescrituras de calado: la versión 1.5 (2011) trajo un nuevo editor de layouts y un nuevo panel de administración; la rama 2.x (2014), una arquitectura modular y un sistema de extensiones mejorado; la versión 3.0 (2017), el modo multitienda y el Marketplace; y la rama 4.x pasó a ser la actual, con la base de código renovada y el regreso de OCMOD. Todas estas ramas siguen apareciendo en proyectos vivos.

Esto es lo que el scraper debe tener en cuenta:

OpenCart 1.5.x: rama obsoleta, pero que todavía se encuentra. Estructura de tablas sencilla, aunque con nombres de campos distintos en varias tablas. Los módulos de importación listos apenas se mantienen ya para ella.

OpenCart 2.x: la rama «antigua» en producción más extendida. Aquí viven muchos de los módulos de importación más contrastados. Las URL SEO se guardan en la tabla url_alias.

OpenCart 3.x: la versión más popular en tiendas en producción en el momento de escribir esto. Estructura cercana a 2.x; las URL SEO, también en url_alias. La mayoría de los scrapers y módulos listos están pensados precisamente para 2.x--3.x.

OpenCart 4.x: la rama actual. Trae un cambio de fondo: las URL SEO se mudaron de url_alias a la tabla seo_url (vinculadas a store_id y language_id), y parte de los controladores también cambió. Un scraper escrito para OC3 no dejará URL amigables correctas en OC4 sin retoques.

Forks y ensamblajes basados en OpenCart: en el mercado circulan distribuciones modificadas de OpenCart con plantillas y módulos preinstalados. Su estructura de datos coincide con la versión de OpenCart correspondiente, pero los módulos «de serie» a veces se comportan de otra manera, así que conviene probarlos siempre por separado.

Conclusión práctica: antes de escribir o comprar un scraper, fije la versión exacta de la tienda (visible en el pie del panel de administración o en el index.php del núcleo) y el prefijo de las tablas de la base (por defecto oc_, aunque a menudo se cambia por otro).


Soluciones listas: qué ofrece el mercado

Si el presupuesto es ajustado y la tienda es de corte estándar, puede que ni siquiera haga falta escribir un scraper propio. Herramientas listas no faltan, y se dividen en varias categorías.

Módulos de importación dentro de OpenCart

Son extensiones que no «recorren» por sí mismas sitios ajenos, sino que reciben un archivo ya preparado (CSV / XLS / XLSX / XML) y lo cargan en el catálogo:

  • Export/Import Tool — módulo gratuito y muy extendido; asume importaciones de hasta varios miles de productos (los límites dependen del hosting). Una buena opción para empezar.
  • Módulos comerciales de importación CSV del Marketplace oficial de OpenCart — de pago, con licencia por dominio, y más flexibles a la hora de configurar la correspondencia de campos.
  • Extensiones multiformato — módulos comerciales capaces de importar CSV, XLS, XLSX y XML de una sola vez, orientados a OC 2--3.
  • Módulos de importación masiva por cola AJAX — cargan y actualizan catálogos grandes por lotes, sin chocar con los límites de tiempo de ejecución del servidor.

La combinación «el scraper genera el archivo → el módulo de importación lo sube» es la más fiable: usted revisa los datos con sus propios ojos antes de cargarlos y no toca nada directamente en la base.

Módulos integrales: scraper e importador en uno

Son soluciones «todo en uno» dentro del ecosistema de extensiones de OpenCart: ellas mismas scrapean los sitios de origen y cargan el resultado en la tienda. Dos perfiles típicos:

  • Módulos con scraper integrado — extracción multihilo de los sitios de origen, traducción automática de textos, generación de descripciones con IA, planificador de tareas y mapeo flexible de datos; lo habitual es una licencia anual por dominio.
  • Módulos de importación masiva avanzada — crean el árbol de categorías, cargan miles de productos con descripciones, opciones, atributos y fotos, y saben estandarizar los atributos «al vuelo».

Programas externos y servicios en la nube

  • Octoparse — herramienta visual de scraping, de escritorio y en la nube, con decenas de plantillas listas para sitios populares; recopila datos de tiendas y marketplaces y los exporta a CSV/Excel para pasarlos después por un módulo de importación.
  • Apify — plataforma de scraping en la nube con «actors» listos para sitios conocidos, ejecuciones programadas y exportación vía API; encaja bien cuando el catálogo del proveedor debe sincronizarse con regularidad.

¿Cuándo no basta una solución lista? Cuando el sitio del proveedor tiene un maquetado poco trivial o protección anti-scraping, cuando hace falta una lógica de márgenes específica, la conversión de divisas con su propio tipo de cambio, el encaje en su propia estructura de categorías o la sincronización periódica de stock. En esos casos se escribe un scraper a medida — y a eso vamos ahora.


Ejemplo: un scraper propio con importación y conversión de divisas

Analicemos un scraper didáctico en Python que:

  1. recorre el catálogo del proveedor y recopila las fichas de producto;
  2. extrae el nombre, el precio, la descripción, la referencia (SKU) y la imagen;
  3. convierte el precio de la divisa del proveedor a la divisa de la tienda con el tipo de cambio actual y el margen configurado;
  4. importa los productos directamente en la base de datos de OpenCart.

El código tiene fines didácticos. Antes de ejecutarlo en una tienda en producción, haga sin falta una copia de seguridad de la base, compruebe que el scraping del sitio de origen no vulnera sus condiciones de uso ni la ley, y pruébelo todo en una copia de la tienda.

Stack y preparación

bash
pip install requests beautifulsoup4 pymysql lxml
  • requests + beautifulsoup4 — descarga y parseo del HTML;
  • pymysql — escritura en la base de OpenCart;
  • el tipo de cambio lo tomamos de una fuente pública (en el ejemplo, el feed XML de tipos de referencia del Banco Central Europeo; puede sustituirse por cualquier otra).

Paso 1. Conversor de divisas

Primero, un módulo aparte que obtiene el tipo de cambio y recalcula el precio con el margen. El tipo se cachea para no llamar a la fuente con cada producto.

python
import requests
import xml.etree.ElementTree as ET
from datetime import date

class CurrencyConverter:
    """Obtiene el tipo de referencia del BCE y convierte el precio del proveedor a la divisa de la tienda."""

    ECB_URL = "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml"

    def __init__(self, source_currency="USD", markup=1.20):
        # markup=1.20 — margen de la tienda: +20% sobre el tipo de cambio
        self.source_currency = source_currency
        self.markup = markup
        self._rate = None
        self._rate_date = None

    def _fetch_rate(self):
        """Busca en el XML del BCE el tipo de la divisa frente al euro."""
        resp = requests.get(self.ECB_URL, timeout=15)
        tree = ET.fromstring(resp.text)

        for cube in tree.iter():
            if cube.get("currency") == self.source_currency:
                return float(cube.get("rate"))  # unidades de divisa por 1 EUR

        raise ValueError(f"Divisa {self.source_currency} no encontrada en la respuesta del BCE")

    @property
    def rate(self):
        # cacheamos el tipo de cambio para la fecha actual
        if self._rate is None or self._rate_date != date.today():
            self._rate = self._fetch_rate()
            self._rate_date = date.today()
        return self._rate

    def convert(self, amount):
        """Precio del proveedor -> precio de la tienda (en euros) con el margen."""
        if amount is None:
            return None
        price = float(amount) / self.rate * self.markup
        return round(price, 2)

Paso 2. Scraper de fichas del proveedor

Los selectores (.product-card, .price, etc.) son orientativos: se ajustan individualmente para cada sitio de origen tras revisar el código fuente de la página.

python
import time
import requests
from bs4 import BeautifulSoup
from urllib.parse import urljoin

class SupplierParser:
    def __init__(self, base_url, converter, delay=1.0):
        self.base_url = base_url
        self.converter = converter
        self.delay = delay  # pausa entre peticiones para no «tumbar» el sitio de origen
        self.session = requests.Session()
        self.session.headers.update({
            "User-Agent": "Mozilla/5.0 (compatible; CatalogImporter/1.0)"
        })

    def _get_soup(self, url):
        resp = self.session.get(url, timeout=20)
        resp.raise_for_status()
        return BeautifulSoup(resp.text, "lxml")

    def parse_catalog(self, catalog_path, max_pages=5):
        """Recopila los enlaces a productos de las páginas del catálogo con paginación."""
        product_urls = []
        for page in range(1, max_pages + 1):
            url = urljoin(self.base_url, f"{catalog_path}?page={page}")
            soup = self._get_soup(url)
            cards = soup.select(".product-card a.product-link")
            if not cards:
                break  # no hay más páginas
            for a in cards:
                product_urls.append(urljoin(self.base_url, a["href"]))
            time.sleep(self.delay)
        return product_urls

    def parse_product(self, url):
        """Procesa una ficha de producto."""
        soup = self._get_soup(url)

        name = soup.select_one("h1.product-title")
        price_raw = soup.select_one(".price .value")
        description = soup.select_one(".product-description")
        sku = soup.select_one(".sku")
        image = soup.select_one(".product-gallery img")

        # limpiamos el precio de espacios y símbolos de divisa
        price_source = None
        if price_raw:
            digits = "".join(ch for ch in price_raw.text if ch.isdigit() or ch == ".")
            price_source = float(digits) if digits else None

        time.sleep(self.delay)

        return {
            "name": name.text.strip() if name else "Sin nombre",
            "sku": sku.text.strip() if sku else "",
            "description": description.decode_contents().strip() if description else "",
            "image_url": urljoin(self.base_url, image["src"]) if image else "",
            "price_source": price_source,
            # aquí es donde se produce la conversión de divisas:
            "price": self.converter.convert(price_source),
            "source_url": url,
        }

Paso 3. Importación a la base de datos de OpenCart

Aquí aplicamos lo dicho al principio: la estructura depende de la versión. El ejemplo está pensado para OpenCart 3.x; la diferencia clave para OC4 va señalada en un comentario.

python
import pymysql
from datetime import datetime
from slugify import slugify  # pip install python-slugify

class OpenCartImporter:
    def __init__(self, db_config, prefix="oc_", store_id=0, language_id=1):
        self.conn = pymysql.connect(**db_config, charset="utf8mb4",
                                    cursorclass=pymysql.cursors.DictCursor)
        self.prefix = prefix
        self.store_id = store_id
        self.language_id = language_id

    def _seo_keyword(self, name):
        return slugify(name) or "product"

    def import_product(self, p, category_id=None):
        cur = self.conn.cursor()
        now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        px = self.prefix

        # 1) tabla principal del producto
        cur.execute(f"""
            INSERT INTO {px}product
                (model, sku, quantity, price, image, status,
                 date_added, date_modified, date_available)
            VALUES (%s, %s, %s, %s, %s, 1, %s, %s, %s)
        """, (p["sku"] or p["name"][:64], p["sku"], 100,
              p["price"], "", now, now, now))
        product_id = cur.lastrowid

        # 2) descripción (nombre, texto, metadatos) — para cada idioma
        cur.execute(f"""
            INSERT INTO {px}product_description
                (product_id, language_id, name, description,
                 meta_title, meta_description)
            VALUES (%s, %s, %s, %s, %s, %s)
        """, (product_id, self.language_id, p["name"],
              p["description"], p["name"], p["name"]))

        # 3) vínculo con la tienda
        cur.execute(f"""
            INSERT INTO {px}product_to_store (product_id, store_id)
            VALUES (%s, %s)
        """, (product_id, self.store_id))

        # 4) vínculo con la categoría (si se indica)
        if category_id:
            cur.execute(f"""
                INSERT INTO {px}product_to_category (product_id, category_id)
                VALUES (%s, %s)
            """, (product_id, category_id))

        # 5) SEO URL (URL amigable)
        keyword = f"{self._seo_keyword(p['name'])}-{product_id}"
        # — OpenCart 3.x: tabla url_alias ---
        cur.execute(f"""
            INSERT INTO {px}url_alias (query, keyword)
            VALUES (%s, %s)
        """, (f"product_id={product_id}", keyword))
        # — En OpenCart 4.x sería así (tabla seo_url):
        # cur.execute(f'''INSERT INTO {px}seo_url
        #   (store_id, language_id, key, value, keyword)
        #   VALUES (%s, %s, "product_id", %s, %s)''',
        #   (self.store_id, self.language_id, product_id, keyword))

        self.conn.commit()
        return product_id

    def close(self):
        self.conn.close()

Paso 4. Lo unimos todo

python
def main():
    converter = CurrencyConverter(source_currency="USD", markup=1.25)
    parser = SupplierParser("https://supplier-example.com", converter, delay=1.0)

    importer = OpenCartImporter(
        db_config={
            "host": "localhost",
            "user": "db_user",
            "password": "db_password",
            "database": "opencart_db",
        },
        prefix="oc_",
        store_id=0,
        language_id=1,
    )

    product_urls = parser.parse_catalog("/catalog/category-1", max_pages=3)
    print(f"Productos encontrados: {len(product_urls)}")

    imported = 0
    for url in product_urls:
        try:
            product = parser.parse_product(url)
            if product["price"] is None:
                print(f"Omitido (sin precio): {url}")
                continue
            pid = importer.import_product(product, category_id=20)
            imported += 1
            print(f"[{imported}] {product['name']} — "
                  f"{product['price_source']} {converter.source_currency} "
                  f"-> {product['price']} €  (ID {pid})")
        except Exception as e:
            print(f"Error en {url}: {e}")

    importer.close()
    print(f"Listo. Importados: {imported}")

if __name__ == "__main__":
    main()

Qué queda fuera del ejemplo y es importante en un proyecto real

El ejemplo didáctico está simplificado a propósito. En un scraper en producción hay que prever, además:

  • La descarga y vinculación de imágenes — bajar las fotos a image/catalog/... y registrar la ruta en product.image y oc_product_image. A menudo los sitios de origen devuelven 403 ante la descarga directa, y hacen falta un referer y unas cabeceras correctos.
  • La deduplicación — comprobar por sku/model si el producto ya existe en la base y actualizar su precio y stock en lugar de multiplicar duplicados. Esto mismo convierte un scraping puntual en una sincronización periódica.
  • Atributos y opcionesoc_product_attribute y oc_product_option, con su propia estructura de relaciones.
  • Categorías — creación automática del árbol de categorías según la estructura del sitio de origen (oc_category, oc_category_description, oc_category_path).
  • La limpieza de la caché de OpenCart y la regeneración de los índices SEO tras una importación masiva.
  • El tipo de cambio y el margen — guardarlos en la configuración y registrar en los logs con qué tipo se recalculó cada lote, para que los precios sean reproducibles y transparentes.
  • Ética y legalidad — respetar el robots.txt, mantener pausas razonables entre peticiones y cumplir las condiciones de uso del sitio de origen.

El mismo scraper en PHP — directamente en el servidor de la tienda

Python es cómodo, pero exige un entorno aparte. OpenCart, en cambio, está escrito en PHP y funciona sobre MySQL, así que el scraper puede construirse con ese mismo stack y ejecutarse directamente en el hosting de la tienda, sin instalar Python ni runtimes de terceros. Ventajas de este enfoque:

  • no hay que instalar nada adicional: PHP ya está en el servidor;
  • se puede reutilizar el config.php de OpenCart para no duplicar las credenciales de la base;
  • es fácil programarlo con cron para una sincronización periódica;
  • si se desea, el script puede engancharse al núcleo de OpenCart y escribir los productos a través de sus modelos, en lugar de atacar las tablas directamente.

Para el parseo del HTML usamos los nativos DOMDocument + DOMXPath (sin bibliotecas de terceros); para las peticiones, cURL; para la base, PDO.

Paso 1. Conversor de divisas

php
<?php
class CurrencyConverter
{
    private const ECB_URL = 'https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml';

    private ?float $rate = null;
    private ?string $rateDate = null;

    public function __construct(
        private string $sourceCurrency = 'USD',
        private float $markup = 1.20  // margen de la tienda: +20% sobre el tipo de cambio
    ) {}

    private function fetchRate(): float
    {
        $xmlRaw = file_get_contents(self::ECB_URL);

        $xml = new SimpleXMLElement($xmlRaw);
        $xml->registerXPathNamespace('e', 'http://www.ecb.int/vocabulary/2002-08-01/eurofxref');

        foreach ($xml->xpath('//e:Cube[@currency]') as $cube) {
            if ((string)$cube['currency'] === $this->sourceCurrency) {
                return (float)$cube['rate']; // unidades de divisa por 1 EUR
            }
        }
        throw new RuntimeException("Divisa {$this->sourceCurrency} no encontrada en la respuesta del BCE");
    }

    public function getRate(): float
    {
        $today = date('Y-m-d');
        if ($this->rate === null || $this->rateDate !== $today) {
            $this->rate = $this->fetchRate();
            $this->rateDate = $today;
        }
        return $this->rate;
    }

    public function convert(?float $amount): ?float
    {
        if ($amount === null) {
            return null;
        }
        return round($amount / $this->getRate() * $this->markup, 2);
    }
}

Paso 2. Scraper de fichas del proveedor

php
<?php
class SupplierParser
{
    public function __construct(
        private string $baseUrl,
        private CurrencyConverter $converter,
        private float $delay = 1.0  // pausa entre peticiones, en segundos
    ) {}

    private function getHtml(string $url): string
    {
        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_FOLLOWLOCATION => true,
            CURLOPT_TIMEOUT        => 20,
            CURLOPT_USERAGENT      => 'Mozilla/5.0 (compatible; CatalogImporter/1.0)',
        ]);
        $html = curl_exec($ch);
        if ($html === false) {
            throw new RuntimeException('cURL: ' . curl_error($ch));
        }
        curl_close($ch);
        return $html;
    }

    private function makeXPath(string $html): DOMXPath
    {
        $doc = new DOMDocument();
        libxml_use_internal_errors(true); // silenciamos los errores del HTML «sucio»
        $doc->loadHTML('<?xml encoding="UTF-8">' . $html);
        libxml_clear_errors();
        return new DOMXPath($doc);
    }

    private function absolute(string $href): string
    {
        return str_starts_with($href, 'http') ? $href : rtrim($this->baseUrl, '/') . '/' . ltrim($href, '/');
    }

    /** Recopila los enlaces a productos de las páginas del catálogo con paginación. */
    public function parseCatalog(string $catalogPath, int $maxPages = 5): array
    {
        $urls = [];
        for ($page = 1; $page <= $maxPages; $page++) {
            $html = $this->getHtml($this->absolute("{$catalogPath}?page={$page}"));
            $xpath = $this->makeXPath($html);
            $links = $xpath->query("//div[contains(@class,'product-card')]//a[contains(@class,'product-link')]");

            if ($links->length === 0) {
                break; // no hay más páginas
            }
            foreach ($links as $a) {
                $urls[] = $this->absolute($a->getAttribute('href'));
            }
            usleep((int)($this->delay * 1_000_000));
        }
        return $urls;
    }

    /** Procesa una ficha de producto. */
    public function parseProduct(string $url): array
    {
        $xpath = $this->makeXPath($this->getHtml($url));

        $text = fn(string $q) => trim($xpath->query($q)->item(0)?->textContent ?? '');

        $name        = $text("//h1[contains(@class,'product-title')]") ?: 'Sin nombre';
        $sku         = $text("//*[contains(@class,'sku')]");
        $description = $text("//*[contains(@class,'product-description')]");
        $priceRaw    = $text("//*[contains(@class,'price')]//*[contains(@class,'value')]");

        $imgNode = $xpath->query("//*[contains(@class,'product-gallery')]//img")->item(0);
        $imageUrl = $imgNode ? $this->absolute($imgNode->getAttribute('src')) : '';

        // limpiamos el precio de espacios y símbolos de divisa
        $priceSource = null;
        if ($priceRaw !== '') {
            $digits = preg_replace('/[^0-9.]/', '', str_replace(',', '.', $priceRaw));
            $priceSource = $digits !== '' ? (float)$digits : null;
        }

        usleep((int)($this->delay * 1_000_000));

        return [
            'name'         => $name,
            'sku'          => $sku,
            'description'  => $description,
            'image_url'    => $imageUrl,
            'price_source' => $priceSource,
            // conversión de divisas:
            'price'        => $this->converter->convert($priceSource),
            'source_url'   => $url,
        ];
    }
}

Paso 3. Importación a la base de datos de OpenCart

php
<?php
class OpenCartImporter
{
    private PDO $pdo;

    public function __construct(
        array $dbConfig,
        private string $prefix = 'oc_',
        private int $storeId = 0,
        private int $languageId = 1
    ) {
        $dsn = "mysql:host={$dbConfig['host']};dbname={$dbConfig['database']};charset=utf8mb4";
        $this->pdo = new PDO($dsn, $dbConfig['user'], $dbConfig['password'], [
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        ]);
    }

    private function seoKeyword(string $name): string
    {
        // generación simplificada de la URL amigable (sin acentos ni caracteres especiales)
        $slug = mb_strtolower(trim($name));
        $slug = strtr($slug, ['á' => 'a', 'é' => 'e', 'í' => 'i', 'ó' => 'o', 'ú' => 'u', 'ü' => 'u', 'ñ' => 'n']);
        $slug = preg_replace('/[^a-z0-9\s-]/u', '', $slug);
        $slug = preg_replace('/\s+/u', '-', $slug);
        return $slug !== '' ? $slug : 'product';
    }

    public function importProduct(array $p, ?int $categoryId = null): int
    {
        $px = $this->prefix;
        $now = date('Y-m-d H:i:s');

        // 1) tabla principal del producto
        $stmt = $this->pdo->prepare("
            INSERT INTO {$px}product
                (model, sku, quantity, price, image, status,
                 date_added, date_modified, date_available)
            VALUES (:model, :sku, 100, :price, '', 1, :added, :modified, :available)
        ");
        $stmt->execute([
            ':model'     => $p['sku'] ?: mb_substr($p['name'], 0, 64),
            ':sku'       => $p['sku'],
            ':price'     => $p['price'],
            ':added'     => $now,
            ':modified'  => $now,
            ':available' => $now,
        ]);
        $productId = (int)$this->pdo->lastInsertId();

        // 2) descripción (nombre, texto, metadatos)
        $this->pdo->prepare("
            INSERT INTO {$px}product_description
                (product_id, language_id, name, description, meta_title, meta_description)
            VALUES (?, ?, ?, ?, ?, ?)
        ")->execute([
            $productId, $this->languageId, $p['name'],
            $p['description'], $p['name'], $p['name'],
        ]);

        // 3) vínculo con la tienda
        $this->pdo->prepare("
            INSERT INTO {$px}product_to_store (product_id, store_id) VALUES (?, ?)
        ")->execute([$productId, $this->storeId]);

        // 4) vínculo con la categoría
        if ($categoryId !== null) {
            $this->pdo->prepare("
                INSERT INTO {$px}product_to_category (product_id, category_id) VALUES (?, ?)
            ")->execute([$productId, $categoryId]);
        }

        // 5) SEO URL (URL amigable)
        $keyword = $this->seoKeyword($p['name']) . '-' . $productId;
        // — OpenCart 3.x: tabla url_alias ---
        $this->pdo->prepare("
            INSERT INTO {$px}url_alias (query, keyword) VALUES (?, ?)
        ")->execute(["product_id={$productId}", $keyword]);
        // — En OpenCart 4.x sería así (tabla seo_url):
        // INSERT INTO {$px}seo_url (store_id, language_id, `key`, `value`, keyword)
        //   VALUES (:store, :lang, 'product_id', :pid, :keyword)

        return $productId;
    }
}

Paso 4. Punto de entrada y ejecución por cron

php
<?php
require 'CurrencyConverter.php';
require 'SupplierParser.php';
require 'OpenCartImporter.php';

$converter = new CurrencyConverter('USD', 1.25);
$parser    = new SupplierParser('https://supplier-example.com', $converter, 1.0);

// las credenciales pueden ir en un archivo aparte o tomarse del config.php de OpenCart
$importer = new OpenCartImporter([
    'host'     => 'localhost',
    'user'     => 'db_user',
    'password' => 'db_password',
    'database' => 'opencart_db',
], prefix: 'oc_', storeId: 0, languageId: 1);

$urls = $parser->parseCatalog('/catalog/category-1', maxPages: 3);
echo 'Productos encontrados: ' . count($urls) . PHP_EOL;

$imported = 0;
foreach ($urls as $url) {
    try {
        $product = $parser->parseProduct($url);
        if ($product['price'] === null) {
            echo "Omitido (sin precio): {$url}" . PHP_EOL;
            continue;
        }
        $pid = $importer->importProduct($product, categoryId: 20);
        $imported++;
        printf("[%d] %s — %s USD -> %s €  (ID %d)%s",
            $imported, $product['name'], $product['price_source'], $product['price'], $pid, PHP_EOL);
    } catch (Throwable $e) {
        echo "Error en {$url}: {$e->getMessage()}" . PHP_EOL;
    }
}

echo "Listo. Importados: {$imported}" . PHP_EOL;

Programar la ejecución automática diaria es una línea en el crontab:

bash
# cada día a las 4:00 sincronizamos el catálogo con el proveedor
0 4 * * * /usr/bin/php /var/www/opencart/parser/run.php >> /var/www/opencart/parser/parser.log 2>&1

Las credenciales de la base pueden tomarse directamente de OpenCart: require '/path/to/opencart/config.php'; — tras esto quedan disponibles las constantes DB_HOSTNAME, DB_USERNAME, DB_PASSWORD, DB_DATABASE y DB_PREFIX, y no hará falta duplicar contraseñas en el scraper.

Todas las salvedades del apartado «fuera del ejemplo» (imágenes, deduplicación, atributos, caché, legalidad) siguen igual de vigentes para la versión en PHP.


Conclusión

Para una tienda estándar suele bastar la combinación «scraper listo + módulo de importación»: Export/Import Tool, los módulos comerciales del Marketplace o herramientas como Octoparse y Apify cubren la mayoría de los escenarios. Pero en cuanto aparece un sitio de origen atípico, una lógica de márgenes propia, la conversión de divisas con el tipo de cambio actual o la sincronización periódica de stock para una versión concreta de OpenCart, resulta más rentable y fiable encargar un scraper a medida.


¿Necesita scraping de tiendas online?

Si necesita poblar o sincronizar una tienda en OpenCart (o en cualquier otra plataforma), recopilar datos de los sitios de sus proveedores o configurar la actualización automática de precios con conversión de divisas, póngase en contacto con nosotros. Diseñaremos e implantaremos un scraper para su versión de OpenCart y sus tareas concretas.