CMS y plataformas 14 min de lectura

Scraper e importador para PrestaShop: soluciones listas y ejemplo con conversión de divisas

Scraper para tiendas PrestaShop: cómo está organizada la plataforma, qué módulos de importación ofrece el marketplace Addons y un ejemplo con conversión de divisas.

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

Rellenar el catálogo a mano sale caro y lleva demasiado tiempo, sobre todo cuando el proveedor tiene miles de referencias y los precios y el stock cambian cada semana. La solución es un scraper para PrestaShop: un programa que recopila los productos del sitio del proveedor (o de su lista de precios) y los carga en su tienda con nombres, descripciones, fotos, características y — esto es lo crítico — con los precios correctamente recalculados.

En este artículo veremos cómo está organizado PrestaShop desde el punto de vista de la importación, qué soluciones listas existen y escribiremos nuestro propio scraper, que recopila los datos del sitio de un proveedor y convierte la divisa en paralelo, tomando el tipo de cambio del fichero diario de referencia del Banco Central Europeo.


Por qué importan la versión y la arquitectura de PrestaShop

Igual que en otras plataformas de e-commerce serias, en PrestaShop no se recomienda en absoluto escribir los productos directamente en la base de datos: la estructura de almacenamiento es compleja (la ficha se reparte entre decenas de tablas: producto, traducciones por idioma, precios, stock, combinaciones, imágenes, categorías) y un INSERT directo casi garantiza romper índices, cachés y lógica de negocio. El camino correcto es trabajar a través de los mecanismos propios de la plataforma: la importación CSV del back office, la API Web Service o las clases PHP del núcleo.

A diferencia de los productos comerciales con ediciones de pago, PrestaShop es software de código abierto: hay una única edición, se descarga gratis y se instala en su propio hosting. El modelo de negocio del ecosistema vive en otra parte: en el marketplace oficial Addons (addons.prestashop.com), donde PrestaShop y los desarrolladores de la comunidad venden módulos y temas, incluidos los módulos de importación de catálogos. Por eso, al planificar un scraper, la pregunta no es «¿qué edición tengo?», sino «¿qué versión de PrestaShop uso y qué mecanismos de importación tengo disponibles?».

Lo que sí pesa es la rama de versiones: la 1.6 lleva años descatalogada, la 1.7 inició la migración progresiva a componentes de Symfony y las ramas actuales son la 8 y la 9. Las tres comparten los mismos conceptos de catálogo, pero los módulos del marketplace se publican para versiones concretas: antes de elegir una solución de importación, compruebe que es compatible con su versión de PrestaShop y con la de PHP de su servidor.

Algunas cosas más que el scraper está obligado a entender sobre PrestaShop:

  • Productos y categorías — cada producto cuelga de un árbol de categorías y tiene una categoría por defecto; los campos de texto (nombre, descripción, URL amigable) son multiidioma y se guardan por idioma.
  • Combinaciones (variantes) — las variantes de un producto (color, talla) se modelan como combinaciones de atributos, con su propia referencia, su impacto en el precio y su stock. Si el proveedor tiene variaciones, el scraper debe crear tanto el producto como sus combinaciones.
  • Multidivisa — PrestaShop soporta varias divisas y puede actualizar los tipos de cambio desde el back office. Aun así, para importar precios de proveedor conviene fijar el precio base ya convertido: nosotros tomaremos el tipo oficial del fichero XML diario del BCE (eurofxref-daily.xml) y aplicaremos el margen aparte, para que los precios sean reproducibles.
  • Importación CSV — el canal estándar de carga masiva: en «Parámetros avanzados → Importar» se suben ficheros de categorías, productos y combinaciones, con correspondencia de columnas configurable e imágenes por URL. Es el punto de enganche natural para un scraper.

Conclusión práctica: antes de desarrollar, fije la versión de PrestaShop, el árbol de categorías, el esquema de características y atributos, y si se usan combinaciones y varios idiomas o divisas. De eso depende toda la lógica de importación.


Soluciones listas: qué hay en el mercado

Si la tienda es típica, quizá ni siquiera haga falta programar nada propio.

Herramientas estándar de PrestaShop

PrestaShop trae de serie dos vías de carga:

  • Importación CSV — «Parámetros avanzados → Importar»: acepta ficheros de productos, categorías y combinaciones, permite hacer corresponder cada columna del fichero con un campo de la ficha (referencia, nombre, descripción, precio, impuestos, imágenes por URL) y guardar esa configuración de correspondencias para cargas repetidas.
  • API Web Service — una API REST integrada (se activa en «Parámetros avanzados → Web Service») con claves de acceso y permisos por recurso: productos, categorías, stock, imágenes. Está pensada justo para integraciones y sincronizaciones externas.

Por sí solas, ninguna de las dos «visita» sitios ajenos: necesitan un fichero o unas peticiones ya preparados. Por eso se suelen emparejar con un scraper: el scraper recopila el CSV (o llama a la API) y la importación estándar lo carga. Es la variante más segura para el núcleo.

Módulos del marketplace oficial Addons

El marketplace Addons reúne miles de módulos, y la importación de catálogos es una de sus categorías clásicas:

  • Importadores avanzados de CSV/Excel/XML — módulos que amplían el importador estándar: cargas programadas desde una URL o FTP, correspondencias más flexibles, actualización selectiva de precios y stock, importación de combinaciones, características e imágenes.
  • Conectores con proveedores y dropshipping — módulos que sincronizan el catálogo con los feeds de mayoristas o con formatos de feed habituales, aplicando reglas de margen sobre el precio de coste.

Consejo: muchos módulos de pago ofrecen demo o versión de prueba, y todas las fichas de Addons indican la compatibilidad de versiones. Pruebe el módulo contra su proveedor concreto antes de comprar — ni mucho menos todos los sitios se scrapean «de fábrica». Y tenga en cuenta que un módulo de importación sigue necesitando un fichero o feed de origen: la parte de «recorrer el sitio del proveedor» suele quedar fuera de su alcance.

Programas externos

  • Scrapers universales de escritorio y en la nube (Octoparse, ParseHub, WebHarvy y similares) — configuran la extracción del sitio del proveedor de forma visual y exportan CSV o Excel, que luego pasa por el importador estándar de PrestaShop. Funcionan bien mientras el sitio de origen sea sencillo; la conversión de divisas y las reglas de margen habrá que resolverlas en un paso intermedio, por ejemplo con fórmulas en la hoja de cálculo.

¿Cuándo no basta con lo listo? Con un marcado no estándar o protecciones anti-bot en el sitio de origen, con una lógica de márgenes propia, con conversión de divisas a un tipo controlado por usted, con un esquema particular de características y combinaciones o con sincronización regular de stock. Entonces se escribe un scraper a medida — y a eso vamos.


Ejemplo 1: scraper en PHP con las clases de PrestaShop (con conversión de divisas)

Como PrestaShop está escrito en PHP, lo más directo es un script PHP que carga el núcleo de PrestaShop y escribe los productos a través de sus clases. La conversión de divisas la haremos con el fichero de referencia del BCE, y el margen lo aplicaremos nosotros.

El código es didáctico. Antes de lanzarlo contra una tienda en producción, haga una copia de seguridad, asegúrese de que scrapear el sitio de origen no infringe sus condiciones ni la ley, y pruebe en una copia.

Paso 0. Carga del núcleo

El script se ejecuta desde la consola o por cron. Basta con incluir config.inc.php: eso inicializa la configuración, la conexión a la base de datos y el autoload de clases.

php
<?php
// para que el script funcione desde la consola/cron y no solo en contexto web
require '/var/www/prestashop/config/config.inc.php';

// contexto mínimo: tienda e idioma por defecto
Shop::setContext(Shop::CONTEXT_SHOP, (int) Configuration::get('PS_SHOP_DEFAULT'));
$idLang = (int) Configuration::get('PS_LANG_DEFAULT');

Paso 1. Conversor de divisas con el fichero del BCE

El Banco Central Europeo publica cada día laborable los tipos de referencia del euro en un XML público: eurofxref-daily.xml. Tomamos de ahí el tipo de cambio, convertimos el precio del proveedor usando el euro como divisa puente y añadimos el margen por separado.

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

    /** @var array<string, float> tipos BCE: cuántas unidades de divisa vale 1 EUR */
    private array $rates = [];

    public function __construct(
        private string $from = 'USD',
        private string $to = 'EUR',
        private float $markup = 1.20   // margen de la tienda: +20 %
    ) {}

    private function rate(string $currency): float
    {
        if ($currency === 'EUR') {
            return 1.0;
        }
        if ($this->rates === []) {
            $xml = simplexml_load_file(self::ECB_URL);
            foreach ($xml->Cube->Cube->Cube as $cube) {
                $this->rates[(string) $cube['currency']] = (float) $cube['rate'];
            }
        }
        if (!isset($this->rates[$currency])) {
            throw new RuntimeException("No hay tipo BCE para {$currency}");
        }
        return $this->rates[$currency];
    }

    /** Precio del proveedor -> precio de la tienda al tipo del BCE + margen. */
    public function convert(?float $amount): ?float
    {
        if ($amount === null) {
            return null;
        }
        // el BCE cotiza todo contra el euro: pasamos por EUR como divisa puente
        $converted = $amount * $this->rate($this->to) / $this->rate($this->from);
        return round($converted * $this->markup, 2);
    }
}

Paso 2. Scraper de fichas del proveedor

Para parsear el HTML usamos los DOMDocument + DOMXPath nativos y cURL, sin bibliotecas de terceros. Los selectores son ficticios: se ajustan individualmente para cada sitio de origen.

php
<?php
class SupplierParser
{
    public function __construct(
        private string $baseUrl,
        private CurrencyConverter $converter,
        private float $delay = 1.0
    ) {}

    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 xpath(string $html): DOMXPath
    {
        $doc = new DOMDocument();
        libxml_use_internal_errors(true);
        $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, '/');
    }

    /** Enlaces de producto desde las páginas de catálogo con paginación. */
    public function parseCatalog(string $path, int $maxPages = 5): array
    {
        $urls = [];
        for ($page = 1; $page <= $maxPages; $page++) {
            $xp = $this->xpath($this->getHtml($this->absolute("{$path}?page={$page}")));
            $links = $xp->query("//div[contains(@class,'product-card')]//a[contains(@class,'product-link')]");
            if ($links->length === 0) {
                break;
            }
            foreach ($links as $a) {
                $urls[] = $this->absolute($a->getAttribute('href'));
            }
            usleep((int)($this->delay * 1_000_000));
        }
        return $urls;
    }

    /** Parseo de una ficha de producto. */
    public function parseProduct(string $url): array
    {
        $xp = $this->xpath($this->getHtml($url));
        $text = fn(string $q) => trim($xp->query($q)->item(0)?->textContent ?? '');

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

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

        $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'  => $descr,
            'image_url'    => $imageUrl,
            'price_source' => $priceSource,
            'price'        => $this->converter->convert($priceSource), // conversión de divisa
            'source_url'   => $url,
        ];
    }
}

Paso 3. Importación al catálogo con las clases de PrestaShop

Aquí está la diferencia clave frente a tocar la base de datos: escribimos a través de Product (la ficha), StockAvailable (el stock) e Image (las imágenes), y PrestaShop se encarga de las tablas, las cachés y los índices. Los duplicados se controlan por la referencia (reference), donde guardamos el artículo del proveedor.

php
<?php
class PrestaShopImporter
{
    public function __construct(
        private int $categoryId,   // categoría por defecto
        private int $idLang        // idioma por defecto de la tienda
    ) {}

    /** Buscamos el producto por referencia para no generar duplicados. */
    private function findByReference(string $reference): ?int
    {
        $id = Db::getInstance()->getValue(
            'SELECT id_product FROM ' . _DB_PREFIX_ . 'product WHERE reference = "' . pSQL($reference) . '"'
        );
        return $id ? (int) $id : null;
    }

    public function import(array $p): int
    {
        $reference  = $p['sku'] !== '' ? $p['sku'] : md5($p['source_url']);
        $existingId = $this->findByReference($reference);

        $product = $existingId ? new Product($existingId) : new Product();
        $product->reference           = $reference;   // referencia = artículo del proveedor
        $product->name                = [$this->idLang => $p['name']];
        $product->link_rewrite        = [$this->idLang => Tools::str2url($p['name'])];
        $product->description         = [$this->idLang => $p['description']];
        $product->id_category_default = $this->categoryId;
        $product->price               = $p['price'];  // precio base sin impuestos, ya en EUR
        $product->active              = true;

        if ($existingId) {
            // el producto ya existe: actualizamos campos y precio (sin tocar la imagen)
            $product->update();
        } else {
            if (!$product->add()) {
                throw new RuntimeException('Product::add() ha fallado para ' . $reference);
            }
            $product->addToCategories([$this->categoryId]);

            // descargamos y asociamos la imagen (si el sitio de origen la ofrece)
            if ($p['image_url'] !== '') {
                $image = new Image();
                $image->id_product = (int) $product->id;
                $image->position   = Image::getHighestPosition($product->id) + 1;
                $image->cover      = true;   // la primera imagen pasa a ser la portada
                if ($image->add()) {
                    AdminImportController::copyImg($product->id, $image->id, $p['image_url'], 'products', true);
                }
            }
        }

        // stock disponible del producto (sin combinaciones: id_product_attribute = 0)
        StockAvailable::setQuantity((int) $product->id, 0, 100);

        return (int) $product->id;
    }
}

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

php
<?php
// tras el bloque de carga del núcleo (paso 0) y las clases anteriores:

$converter = new CurrencyConverter('USD', 'EUR', 1.25);
$parser    = new SupplierParser('https://supplier-example.com', $converter, 1.0);
$importer  = new PrestaShopImporter(categoryId: 47, idLang: $idLang);

$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;
        }
        $id = $importer->import($product);
        $imported++;
        printf("[%d] %s — %s USD -> %s EUR  (ID %d)%s",
            $imported, $product['name'], $product['price_source'], $product['price'], $id, PHP_EOL);
    } catch (Throwable $e) {
        echo "Error en {$url}: {$e->getMessage()}" . PHP_EOL;
    }
}

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

Para el arranque automático una vez al día, 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/prestashop/scripts/importer/run.php >> /var/www/prestashop/scripts/importer/importer.log 2>&1

Si su hosting no da acceso al programador de tareas del sistema, la alternativa habitual es exponer el script tras una URL protegida con un token y llamarlo desde el cron del panel del hosting o desde un servicio de cron externo. Para importaciones pesadas, en cualquier caso, el cron de sistema es más fiable: la carga no interfiere con las visitas de la tienda.


Ejemplo 2: recolección en Python → CSV → importador estándar

Si no quiere cargar el núcleo ni tocar el código de la tienda en producción, puede desacoplar el scraping de la importación: recopilar los datos con la herramienta que prefiera (por ejemplo, Python), guardarlos en un CSV y confiar la carga al importador estándar de «Parámetros avanzados → Importar». Así el scraper no depende en absoluto de PrestaShop ni de sus actualizaciones.

Aquí la conversión de divisas corre de nuestra cuenta: tomamos el tipo de referencia del BCE y aplicamos el margen (igual que en la variante PHP, pero en el lado del recolector).

python
import csv
import time
import requests
import xml.etree.ElementTree as ET
from bs4 import BeautifulSoup
from urllib.parse import urljoin
from datetime import date

class EcbRate:
    """Tipo de referencia del BCE con caché por día."""
    URL = "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml"
    NS = {"e": "http://www.ecb.int/vocabulary/2002-08-01/eurofxref"}

    def __init__(self, currency="USD"):
        self.currency = currency
        self._rate = None
        self._date = None

    def rate(self):
        if self._rate is None or self._date != date.today():
            r = requests.get(self.URL, timeout=15)
            tree = ET.fromstring(r.text)
            for cube in tree.findall(".//e:Cube[@currency]", self.NS):
                if cube.attrib["currency"] == self.currency:
                    # el BCE publica cuántas unidades de divisa vale 1 EUR
                    self._rate = float(cube.attrib["rate"])
                    self._date = date.today()
                    break
        return self._rate


def parse_and_export(base_url, catalog_path, out_csv,
                     currency="USD", markup=1.25, max_pages=3, delay=1.0):
    ecb = EcbRate(currency)
    session = requests.Session()
    session.headers["User-Agent"] = "Mozilla/5.0 (compatible; CatalogImporter/1.0)"

    rows = []
    for page in range(1, max_pages + 1):
        soup = BeautifulSoup(
            session.get(f"{base_url}{catalog_path}?page={page}", timeout=20).text, "lxml"
        )
        cards = soup.select(".product-card a.product-link")
        if not cards:
            break
        for a in cards:
            url = urljoin(base_url, a["href"])
            ps = BeautifulSoup(session.get(url, timeout=20).text, "lxml")

            name = ps.select_one("h1.product-title")
            sku = ps.select_one(".sku")
            descr = ps.select_one(".product-description")
            price_el = ps.select_one(".price .value")
            img = ps.select_one(".product-gallery img")

            price_src = None
            if price_el:
                digits = "".join(c for c in price_el.text if c.isdigit() or c == ".")
                price_src = float(digits) if digits else None

            # conversión de divisa: precio / tipo BCE (1 EUR = X USD) * margen
            price_eur = round(price_src / ecb.rate() * markup, 2) if price_src else ""

            rows.append({
                "Reference": sku.text.strip() if sku else url,
                "Name": name.text.strip() if name else "Sin nombre",
                "Description": descr.decode_contents().strip() if descr else "",
                "Price tax excluded": price_eur,
                "Image URLs": urljoin(base_url, img["src"]) if img else "",
            })
            time.sleep(delay)
        time.sleep(delay)

    # CSV para el importador estándar de PrestaShop (separador ;)
    with open(out_csv, "w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=rows[0].keys(), delimiter=";")
        writer.writeheader()
        writer.writerows(rows)

    print(f"Listo. Filas escritas: {len(rows)} -> {out_csv}")


if __name__ == "__main__":
    parse_and_export(
        base_url="https://supplier-example.com",
        catalog_path="/catalog/category-1",
        out_csv="import.csv",
    )

Después, el CSV se sube en el back office: Parámetros avanzados → Importar, entidad «Productos», separador de campos ;; en el segundo paso, cada columna se hace corresponder con un campo de la ficha (referencia, nombre, descripción, precio sin impuestos, URL de la imagen), y esa correspondencia puede guardarse para las cargas siguientes. Ventaja del enfoque: el scraper no depende del núcleo de PrestaShop; inconveniente: la carga no es «al vuelo», sino un paso aparte.


Qué importa en un proyecto real

Los ejemplos didácticos están simplificados a propósito. En un scraper de producción para PrestaShop hay que prever además:

  • Combinaciones — si el producto tiene variantes (talla, color), crear los atributos y sus combinaciones, y colgar el precio y el stock de cada combinación, no solo del producto base.
  • Características y atributos — hacer corresponder las especificaciones del sitio de origen con las características de PrestaShop y, si hace falta, crear automáticamente los valores nuevos.
  • Categorías — generar el árbol de categorías según la estructura del proveedor (con la clase Category o con la importación CSV de categorías).
  • Varios idiomas y divisas — en tiendas multiidioma, rellenar los campos por cada idioma; si vende en varias divisas, decidir si convierte durante la importación o deja que PrestaShop recalcule con sus propios tipos.
  • Deduplicación y sincronización — actualizar precio y stock por reference en lugar de crear duplicados; así un scraping puntual se convierte en una sincronización regular.
  • Tipo de cambio y margen — guardarlos en la configuración y registrar en el log con qué tipo se recalculó cada lote, para que los precios sean reproducibles.
  • Ética y legalidad — respetar robots.txt, hacer pausas entre peticiones y tener en cuenta las condiciones de uso del sitio de origen.

Conclusión

Para una tienda típica suele bastar la pareja «el scraper genera un fichero + importación CSV estándar» o un módulo de importación del marketplace oficial Addons. Pero en cuanto aparecen un sitio de origen no estándar, un esquema propio de características y combinaciones, varios idiomas o divisas, la conversión de divisas con un tipo oficial actualizado o la sincronización regular de stock, lo más fiable es encargar un scraper a medida que escriba a través de los mecanismos nativos de PrestaShop.


¿Necesita scraping de tiendas online?

Si necesita poblar o sincronizar una tienda en PrestaShop (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, escríbanos. Diseñaremos e implantaremos un scraper adaptado a su versión de PrestaShop y a sus tareas.