Web scraping massiu en Python per al CRM d'Odoo (+2M referències)

Arquitectura real d'un sistema d'extracció, normalització i ingesta massiva de dades a Odoo CRM: de les fonts externes al lead qualificat, amb més de dos milions de referències gestionades en producció

De dos milions de referències disperses a un CRM unificat

A Cymit Química gestionàvem un catàleg de més de dos milions de referències procedents de dotzenes de proveïdors nacionals i internacionals. L'alternativa a automatitzar aquella gestió era un equip dedicat a l'actualització manual del catàleg: un equip que mai hauria pogut competir en velocitat amb els cicles d'actualització de preus i disponibilitat que el mercat de distribució química exigeix. El sistema de crawling i scraping que vaig dissenyar i implementar no va ser un projecte d'IT: va ser una decisió de negoci que va convertir aquell volum de dades en un avantatge competitiu real respecte a distribuïdores que no tenien aquella automatització.

Aquesta guia recull l'arquitectura tècnica completa d'aquell sistema, adaptada com a referència per a qualsevol projecte que necessiti alimentar el CRM o el catàleg d'Odoo amb dades extretes de fonts externes a escala.

Arquitectura general: de la font al CRM

Un pipeline de scraping industrial té quatre capes ben diferenciades. Barrejar-les en un únic script és l'error més freqüent i el que fa que els projectes de scraping morin als pocs mesos:

  1. Extracció: obtenir l'HTML o les dades en brut de la font.
  2. Transformació i normalització: netejar, estructurar i unificar el format de les dades.
  3. Deduplicació i validació: assegurar que no s'insereixen registres repetits ni dades incorrectes.
  4. Càrrega a Odoo: escriure al CRM via API amb garanties d'idempotència.

Cada capa té les seves pròpies eines, els seus propis modes de fallada i les seves pròpies estratègies d'escalat. A continuació detallo cadascuna.

Capa 1: extracció -- triar l'eina correcta

No existeix una eina universal per a scraping. L'elecció depèn del que fa la font:

  • Requests + BeautifulSoup / lxml: per a pàgines HTML estàtiques o APIs REST no documentades que retornen JSON. L'opció més lleugera i més ràpida. El 60-70% dels casos d'un distribuïdor B2B es resolen aquí.
  • Scrapy: quan el volum és alt (milers o milions d'URLs) i necessites control fi sobre concurrència, middlewares, pipelines i reintents. Scrapy és un framework, no una llibreria: té corba d'aprenentatge, però la diferència en mantenibilitat a llarg termini ho justifica.
  • Playwright (o Selenium): per a pàgines que requereixen JavaScript per renderitzar el contingut. Playwright és l'opció moderna: més ràpid que Selenium, millor API asíncrona, suport de Chromium/Firefox/WebKit i mode headless natiu.

Al projecte de Cymit vam usar els tres en paral·lel, assignats per tipus de font. L'orquestrador de tasques decidia quin motor usar per a cada proveïdor.

Exemple: extracció amb Requests i lxml per a un catàleg HTML estàtic

import requests
from lxml import html
import time
import random

SESSION_HEADERS = {
    "User-Agent": (
        "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
        "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
    ),
    "Accept-Language": "es-ES,es;q=0.9",
    "Accept-Encoding": "gzip, deflate, br",
}


def fetch_product_page(url: str, session: requests.Session) -> dict | None:
    """Extrae datos de una página de producto individual."""
    try:
        response = session.get(url, headers=SESSION_HEADERS, timeout=15)
        response.raise_for_status()
    except requests.RequestException as exc:
        print(f"[ERROR] {url}: {exc}")
        return None

    tree = html.fromstring(response.content)

    name = tree.xpath("//h1[@class='product-title']/text()")
    ref = tree.xpath("//span[@data-field='product_ref']/text()")
    price = tree.xpath("//span[@class='price-value']/text()")
    availability = tree.xpath("//div[@class='stock-info']//text()")

    return {
        "url": url,
        "name": name[0].strip() if name else None,
        "ref": ref[0].strip() if ref else None,
        "price_raw": price[0].strip() if price else None,
        "availability": " ".join(availability).strip() if availability else None,
    }


def crawl_catalog(base_url: str, page_count: int) -> list[dict]:
    session = requests.Session()
    product_data = []
    for page in range(1, page_count + 1):
        url = f"{base_url}?page={page}"
        resp = session.get(url, headers=SESSION_HEADERS, timeout=15)
        tree = html.fromstring(resp.content)
        links = tree.xpath("//a[@class='product-link']/@href")
        for link in links:
            full_url = f"https://proveedor.example.com{link}"
            data = fetch_product_page(full_url, session)
            if data:
                product_data.append(data)
        time.sleep(random.uniform(1.5, 3.5))
    return product_data

Scrapy per a volum: spider de catàleg distribuïdor

Quan el catàleg supera les desenes de milers de pàgines, la gestió manual de la concurrència, els reintents i les cues es torna inmanejable. Scrapy resol això amb la seva arquitectura de pipelines i middlewares:

import scrapy
from scrapy.http import Response


class CatalogSpider(scrapy.Spider):
    name = "catalog_spider"
    allowed_domains = ["proveedor.example.com"]
    start_urls = ["https://proveedor.example.com/catalogo/"]

    custom_settings = {
        "CONCURRENT_REQUESTS": 4,
        "DOWNLOAD_DELAY": 2,
        "RANDOMIZE_DOWNLOAD_DELAY": True,
        "AUTOTHROTTLE_ENABLED": True,
        "AUTOTHROTTLE_TARGET_CONCURRENCY": 2.0,
        "RETRY_TIMES": 3,
        "RETRY_HTTP_CODES": [429, 500, 502, 503, 504],
        "ITEM_PIPELINES": {
            "catalog_scraper.pipelines.NormalizePipeline": 100,
            "catalog_scraper.pipelines.DedupPipeline": 200,
            "catalog_scraper.pipelines.OdooIngestPipeline": 300,
        },
        "ROTATING_PROXY_LIST_PATH": "/etc/scraper/proxies.txt",
    }

    def parse(self, response: Response):
        for href in response.css("a.product-link::attr(href)").getall():
            yield response.follow(href, callback=self.parse_product)
        next_page = response.css("a.pagination-next::attr(href)").get()
        if next_page:
            yield response.follow(next_page, callback=self.parse)

    def parse_product(self, response: Response):
        yield {
            "source_url": response.url,
            "name": response.css("h1.product-title::text").get("").strip(),
            "ref_supplier": response.css("[data-field=product_ref]::text").get("").strip(),
            "price_raw": response.css(".price-value::text").get("").strip(),
            "description": response.css(".product-description").get(""),
            "availability": response.css(".stock-info::text").get("").strip(),
        }

Playwright per a pàgines amb JavaScript

Alguns portals de proveïdors renderitzen el preu i la disponibilitat via JS. Requests no veu aquelles dades; Playwright sí:

import asyncio
from playwright.async_api import async_playwright


async def scrape_dynamic_page(url: str) -> dict:
    async with async_playwright() as pw:
        browser = await pw.chromium.launch(headless=True)
        context = await browser.new_context(
            user_agent="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/124.0.0.0 Safari/537.36"
        )
        page = await context.new_page()
        api_responses = []

        async def handle_response(response):
            if "/api/product" in response.url:
                try:
                    data = await response.json()
                    api_responses.append(data)
                except Exception:
                    pass

        page.on("response", handle_response)
        await page.goto(url, wait_until="networkidle", timeout=30_000)
        await page.wait_for_selector(".price-value", timeout=10_000)
        name = await page.text_content("h1.product-title")
        price = await page.text_content(".price-value")
        await browser.close()
        return {
            "name": name.strip() if name else None,
            "price_raw": price.strip() if price else None,
            "api_data": api_responses[0] if api_responses else None,
        }

Gestió d'escala i anti-bloqueig

Un scraper que funciona amb cent peticions falla amb un milió. La diferència entre un sistema de scraping artesanal i un d'industrial està en com gestiona els rebuigs, els rate limits i la detecció.

Rate limiting i jitter

La regla d'or és no semblar un robot. Els robots fan peticions a intervals exactes; els humans no. Afegir un component aleatori al delay entre peticions (jitter) redueix dràsticament la taxa de detecció. A Scrapy, RANDOMIZE_DOWNLOAD_DELAY = True ho fa automàticament. En scripts amb Requests, time.sleep(random.uniform(1.5, 4.0)) entre peticions és el mínim.

Rotació de proxies i User-Agents

Per a volums de milions de peticions, una sola IP serà bloquejada inevitablement. La solució és un pool de proxies residencials o datacenter rotatius. A Scrapy, scrapy-rotating-proxies gestiona el pool automàticament, marcant els proxies bloquejats i redistribuint el tràfic entre els actius:

# settings.py de Scrapy
DOWNLOADER_MIDDLEWARES = {
    "rotating_proxies.middlewares.RotatingProxyMiddleware": 610,
    "rotating_proxies.middlewares.BanDetectionMiddleware": 620,
}
ROTATING_PROXY_LIST_PATH = "/etc/scraper/proxies.txt"
ROTATING_PROXY_PAGE_RETRY_TIMES = 5

# proxies.txt: una per línia
# http://user:pass@proxy1.example.com:8080
# http://user:pass@proxy2.example.com:8080

La rotació de User-Agents complementa la de proxies. El middleware scrapy-fake-useragent extreu llistes actualitzades de User-Agents reals de navegadors per a cada petició.

Cues i workers distribuïts amb Celery + Redis

Per a un catàleg de dos milions de referències que s'actualitza periòdicament, un únic procés seqüencial triga dies. La solució és distribuir la feina en workers paral·lels amb una cua de tasques. Celery amb Redis com a broker és l'estàndard de facto en Python:

from celery import Celery

app = Celery(
    "scraper",
    broker="redis://localhost:6379/0",
    backend="redis://localhost:6379/1",
)

app.conf.update(
    task_serializer="json",
    result_serializer="json",
    accept_content=["json"],
    task_acks_late=True,
    worker_prefetch_multiplier=1,
    task_routes={
        "scraper.tasks.scrape_product": {"queue": "scraping"},
        "scraper.tasks.ingest_odoo": {"queue": "odoo_ingest"},
    },
)


@app.task(bind=True, max_retries=3, default_retry_delay=60)
def scrape_product(self, url: str, supplier_id: int) -> dict:
    try:
        data = fetch_product_page(url, requests.Session())
        if data:
            ingest_odoo.delay(data, supplier_id)
        return data
    except Exception as exc:
        raise self.retry(exc=exc)

Amb 8 workers dedicats al scraping, el sistema de Cymit processava desenes de milers de referències per hora en les actualitzacions massives periòdiques.

Capa 2: normalització de dades

Les dades extretes de diferents proveïdors arriben en formats heterogenis: preus amb símbol de moneda i coma decimal, disponibilitat com «En stock» / «Out of stock» / «Disponible en 3-5 días» / «S/D», referències amb o sense prefix de proveïdor. El pipeline de normalització ha de convertir tot això a un esquema unificat abans de tocar la base de dades d'Odoo.

import re
from decimal import Decimal, InvalidOperation


AVAILABILITY_MAP = {
    "en stock": "available",
    "disponible": "available",
    "out of stock": "out_of_stock",
    "no disponible": "out_of_stock",
    "agotado": "out_of_stock",
    "s/d": "out_of_stock",
    r"disponible en \d+-\d+ días": "on_demand",
    r"\d+ semanas": "on_demand",
}


def normalize_price(raw: str) -> Decimal | None:
    if not raw:
        return None
    cleaned = re.sub(r"[^\d.,]", "", raw).strip()
    if "," in cleaned and "." in cleaned:
        cleaned = cleaned.replace(".", "").replace(",", ".")
    elif "," in cleaned:
        cleaned = cleaned.replace(",", ".")
    try:
        return Decimal(cleaned)
    except InvalidOperation:
        return None


def normalize_availability(raw: str) -> str:
    normalized = raw.lower().strip()
    for pattern, status in AVAILABILITY_MAP.items():
        if re.search(pattern, normalized):
            return status
    return "unknown"


def normalize_product(raw: dict, supplier_code: str) -> dict:
    ref = (raw.get("ref_supplier") or "").strip().upper()
    return {
        "external_id": f"{supplier_code}_{ref}",
        "name": (raw.get("name") or "").strip(),
        "default_code": ref,
        "list_price": normalize_price(raw.get("price_raw", "")),
        "availability": normalize_availability(raw.get("availability", "")),
        "description": (raw.get("description") or "").strip(),
        "source_url": raw.get("source_url"),
    }

Capa 3: deduplicació

Amb dos milions de referències i múltiples proveïdors, la mateixa referència pot aparèixer en fonts diferents sota noms lleugerament distints. L'estratègia de deduplicació té dos nivells:

  • Dedup exacte per external_id: l'identificador canònic {supplier_code}_{ref} garanteix que la mateixa referència del mateix proveïdor no s'insereixi mai dues vegades.
  • Dedup fuzzy entre proveïdors: per detectar la mateixa referència venuda per dos distribuïdors distints sota noms diferents, s'usen tècniques de comparació de cadenes com rapidfuzz sobre el nom normalitzat i el número CAS quan està disponible.
from rapidfuzz import fuzz


def find_duplicate_candidate(product, existing_refs, threshold=90):
    name = product["name"].lower()
    for existing in existing_refs:
        score = fuzz.token_sort_ratio(name, existing["name"].lower())
        if score >= threshold:
            return existing
    return None

Capa 4: ingesta a Odoo via API

Odoo exposa dues interfícies d'API: XML-RPC (clàssica, disponible des d'Odoo 6) i JSON-RPC (més moderna, mateixes capacitats). Totes dues permeten autenticació, cerca, creació i actualització de registres de qualsevol model de l'ERP. Per a ingesta massiva des d'un scraper extern, JSON-RPC amb create_or_write via external_id és la combinació correcta.

Connexió i autenticació

import xmlrpc.client


class OdooClient:
    def __init__(self, url, db, username, password):
        self.url = url
        self.db = db
        self.uid = None
        self._models = xmlrpc.client.ServerProxy(f"{url}/xmlrpc/2/object")
        self._common = xmlrpc.client.ServerProxy(f"{url}/xmlrpc/2/common")
        self.password = password
        self._authenticate(username)

    def _authenticate(self, username):
        self.uid = self._common.authenticate(self.db, username, self.password, {})
        if not self.uid:
            raise ValueError(f"Autenticació fallida per a l'usuari {username}")

    def execute(self, model, method, *args, **kwargs):
        return self._models.execute_kw(self.db, self.uid, self.password, model, method, list(args), kwargs)

    def upsert_by_external_id(self, model, external_id, values):
        existing = self.execute(
            "ir.model.data", "search_read",
            [["name", "=", external_id], ["model", "=", model]],
            fields=["res_id"],
        )
        if existing:
            record_id = existing[0]["res_id"]
            self.execute(model, "write", [record_id], values)
            return record_id
        else:
            record_id = self.execute(model, "create", values)
            self.execute("ir.model.data", "create", {
                "name": external_id, "model": model,
                "res_id": record_id, "module": "scraper_import", "noupdate": False,
            })
            return record_id

Ingesta massiva de leads i productes

Per a la ingesta massiva, l'operació individual de upsert_by_external_id per a cada referència és massa lenta. Odoo permet create en batch passant una llista de dicts. Combinat amb el patró de càrrega en chunks, el rendiment millora dràsticament:

def ingest_products_bulk(client, products, chunk_size=100):
    created = updated = errors = 0
    ext_ids_in_odoo = {
        row["name"]: row["res_id"]
        for row in client.execute(
            "ir.model.data", "search_read",
            [["module", "=", "scraper_import"], ["model", "=", "product.template"]],
            fields=["name", "res_id"],
        )
    }
    for i in range(0, len(products), chunk_size):
        chunk = products[i:i + chunk_size]
        to_create = []
        to_update = {}
        for p in chunk:
            ext_id = p.pop("external_id")
            values = {
                "name": p["name"], "default_code": p["default_code"],
                "list_price": float(p["list_price"]) if p["list_price"] else 0.0,
                "description_sale": p.get("description", ""), "type": "product",
            }
            if ext_id in ext_ids_in_odoo:
                to_update[ext_ids_in_odoo[ext_id]] = values
            else:
                to_create.append((ext_id, values))
        if to_create:
            ids = client.execute("product.template", "create", [v for _, v in to_create])
            for (ext_id, _), record_id in zip(to_create, ids):
                client.execute("ir.model.data", "create", {
                    "name": ext_id, "model": "product.template",
                    "res_id": record_id, "module": "scraper_import",
                })
            created += len(to_create)
        for record_id, values in to_update.items():
            client.execute("product.template", "write", [record_id], values)
        updated += len(to_update)
    return {"created": created, "updated": updated, "errors": errors}

Ingesta de leads a crm.lead

Per alimentar el CRM directament amb leads extrets de directoris, formularis o agregadors, el model objectiu és crm.lead. El mateix patró d'external_id garanteix idempotència: el mateix lead detectat en dues execucions distintes del scraper no es duplica:

def ingest_lead(client, lead_data):
    ext_id = lead_data["external_id"]
    values = {
        "name": lead_data["company_name"],
        "partner_name": lead_data.get("contact_name", ""),
        "email_from": lead_data.get("email", ""),
        "phone": lead_data.get("phone", ""),
        "website": lead_data.get("website", ""),
        "street": lead_data.get("address", ""),
        "city": lead_data.get("city", ""),
        "country_id": 69,
        "type": "lead",
        "source_id": lead_data.get("odoo_source_id"),
        "description": lead_data.get("raw_description", ""),
    }
    return client.upsert_by_external_id("crm.lead", ext_id, values)

Aspectes legals i ètics del scraping

Cap guia tècnica de scraping és completa sense aquest apartat, especialment en el context europeu. Aquests són els punts que avaluo en cada projecte abans d'escriure una sola línia de codi d'extracció:

  • Termes d'ús de la font: molts llocs prohibeixen explícitament el scraping automatitzat en els seus ToS. No respectar-ho exposa a responsabilitat civil i possibles accions legals.
  • Dades personals i RGPD: si les dades extretes inclouen informació de persones físiques (noms, correus, telèfons), s'aplica el RGPD. Necessites base legal per al tractament i has de garantir els drets ARCO. Això no és negociable al mercat espanyol.
  • Robots.txt: tot i que no té força legal directa a Espanya, ignorar robots.txt pot agreujar la posició en un litigi i és un senyal de mala fe. Respectar-lo és la pràctica ètica mínima.
  • Contingut amb copyright: copiar i emmagatzemar textos complets de productes pot implicar infracció de drets d'autor. Extreure dades estructurades (preu, disponibilitat, referència) es troba en una zona grisa més favorable que copiar descripcions literals.
  • Càrrega sobre el servidor objectiu: un scraper sense rate limiting pot causar una degradació del servei al lloc objectiu, cosa que en casos extrems pot qualificar-se com a DoS negligent.

A Cymit, l'extracció es realitzava de catàlegs de proveïdors amb els quals teníem acords comercials que incloïen accés a la informació de producte. Aquella relació contractual prèvia és la manera més segura d'operar a escala.

Orquestació i monitorització del pipeline

Un pipeline de scraping en producció necessita observabilitat. Les mètriques mínimes que monitoritzàvem a Cymit incloïen: taxa d'èxit per proveïdor, nombre de referències actualitzades per execució, temps d'execució per crawler, i alertes quan un proveïdor canviava la seva estructura d'HTML (cosa que trencava els selectors). Amb alertes a Telegram via un bot Python senzill, l'equip rebia notificació en menys de cinc minuts quan qualsevol crawler deixava de funcionar, sense haver de revisar logs manualment.

Conclusió

Un sistema de scraping massiu ben dissenyat no és un conjunt de scripts: és una arquitectura de dades amb capes clarament separades, garanties d'idempotència, gestió d'escala i observabilitat. La diferència entre un script que funciona el primer dia i un pipeline que segueix funcionant dos anys després és exactament aquesta: l'arquitectura. A Cymit, aquest sistema va convertir dos milions de referències d'un problema operatiu en un avantatge competitiu que va contribuir a l'exit al Grup PALEX. El mateix plantejament és aplicable a qualsevol projecte que necessiti alimentar Odoo amb dades del món exterior a escala.

Necessites automatitzar la captació de dades al teu Odoo?

Sol·licitar auditoria tècnica gratuïta

Seguretat a Odoo: hardening del servidor i Nginx per a producció
Guia tècnica completa per reduir la superfície d'atac d'Odoo: usuari no-root, tallafoc, fail2ban, TLS, HSTS, rate limiting, capçaleres de seguretat, ocultar el gestor de bases de dades i backups xifrats.