Selector CSS o XPath: ¿cuál usar en web scraping?

Publicado:

16 min de lectura

Acar Diveroli
Autor: Acar Diveroli
Un cubo CSS con .item a la izquierda, una tarjeta de árbol DOM en el centro y un cubo XPath con //a a la derecha

Tras descargar una página empieza el verdadero trabajo del scraping: encontrar y extraer el nombre del producto, el precio y el enlace entre cientos de etiquetas. Qué elemento quieres lo describes con un selector. Hay dos lenguajes habituales: los selectores CSS, que los desarrolladores web conocen por las hojas de estilo, y XPath, que viene del mundo XML. A menudo puedes seleccionar el mismo elemento con cualquiera de los dos, pero uno es corto y legible, y el otro hace cosas que CSS no puede hacer en absoluto.

En este artículo explicamos qué son los dos lenguajes, comparamos su sintaxis, vemos qué puede hacer XPath más allá de CSS y si la diferencia de rendimiento importa de verdad. Después mostramos cómo escribir selectores que no se rompan cuando cambia el diseño de la página y cómo probar selectores en el navegador. En mitad del artículo hay una tabla de referencia rápida de 20 filas que puedes guardar; comprobamos en una página de prueba que cada par CSS y XPath de la tabla selecciona los mismos elementos.

¿Qué es un selector CSS?

Un selector CSS es el lenguaje de patrones que se usa en las hojas de estilo CSS para indicar a qué elementos se aplica un estilo. Los navegadores también admiten este lenguaje en JavaScript mediante document.querySelector() y querySelectorAll(). La definición actual es Selectors Level 4 del W3C.

Un selector CSS elige un elemento según:

  • Etiqueta: div, a, span
  • Clase: .product
  • Id: #list
  • Atributo: a[href], img[src$=".webp"]
  • Jerarquía: ul > li (hijo directo), div span (descendiente a cualquier profundidad)
  • Hermanos: h2 + p (inmediatamente siguiente), h2 ~ span (todos los hermanos siguientes)
  • Posición: li:first-of-type, li:nth-of-type(3)

La dirección básica de CSS es hacia abajo y hacia delante: puedes ir de un elemento a sus descendientes y a sus hermanos siguientes. La pseudoclase :has() introducida en Selectors Level 4 relaja en parte esta regla: div.product:has(> span.discount) selecciona las tarjetas de producto que contienen una etiqueta de descuento. Pero :has() no «sube» desde un elemento; selecciona el elemento exterior mirando lo que contiene.

¿Qué es XPath?

XPath (XML Path Language) es un lenguaje de consulta que selecciona nodos en documentos XML y HTML con una expresión de ruta. Ve el documento como un árbol y puede moverse en cualquier dirección: hacia abajo, hacia arriba y hacia los hermanos anteriores y siguientes. La definición actual del W3C es XPath 3.1.

Hay un detalle práctico importante: los navegadores y la librería lxml de Python admiten XPath 1.0. Las funciones añadidas en versiones posteriores, como ends-with(), las funciones de expresiones regulares y un sistema de tipos más rico, no están disponibles en estos entornos. Mantén las expresiones XPath para scraping dentro de los límites de la versión 1.0.

Las partes básicas de XPath:

  • // cualquier profundidad del documento, / hijo directo: //ul/li
  • [...] una condición (predicado): //a[@href], //li[3]
  • @ atributo: //img/@src
  • text() nodo de texto: //h2/text()
  • Ejes: parent::, ancestor::, following-sibling::, preceding-sibling::
  • Funciones: contains(), starts-with(), normalize-space(), last(), string-length()

En el navegador, XPath se ejecuta con la función de JavaScript document.evaluate(); su uso se describe en la documentación de MDN.

Comparación de sintaxis

CriterioSelector CSSXPath
LegibilidadCorto, conocido por los desarrolladores webLargo, curva de aprendizaje más pronunciada
DirecciónHacia abajo y hacia delante (en parte hacia fuera con :has())Cualquier dirección: padre, hermano anterior, ancestro
Selección por textoNo está en el estándarSí: text(), contains()
Devolver valores de atributosNo (con extensiones de librerías)Sí: /@href
Devolver nodos de textoNo (con extensiones de librerías)Sí: /text()
Coincidencia de clase.product, exacta y cortacontains(@class, ...) requiere cuidado
Soporte en navegadoresquerySelectorAlldocument.evaluate (XPath 1.0)
Soporte en PythonBeautifulSoup, lxml (cssselect), parsellxml, parsel; BeautifulSoup no lo admite
Navegadores headlessPlaywright, Puppeteer, SeleniumPlaywright, Puppeteer, Selenium
Uso típicoExtraer listas por clase y atributoTablas etiqueta-valor, selección por texto

Tabla de referencia rápida

Cada fila de la tabla siguiente es la misma selección en ambos lenguajes. En las filas sin equivalente CSS se muestra lo que XPath puede hacer de más.

#¿Qué se selecciona?Selector CSSXPath
1Todos los elementos divdiv//div
2Por id#list//*[@id="list"]
3Por clase.product//*[contains(concat(" ", normalize-space(@class), " "), " product ")]
4Hijo directoul > li//ul/li
5Descendiente a cualquier profundidaddiv span//div//span
6Tiene el atributoa[href]//a[@href]
7El valor del atributo es igual ainput[name="q"]//input[@name="q"]
8El atributo empieza pora[href^="https"]//a[starts-with(@href, "https")]
9El atributo contienea[href*="product"]//a[contains(@href, "product")]
10El atributo termina enimg[src$=".webp"]//img[substring(@src, string-length(@src) - 4) = ".webp"]
11Primer elementoul > li:first-of-type//ul/li[1]
12Último elementoul > li:last-of-type//ul/li[last()]
13Tercer elementoul > li:nth-of-type(3)//ul/li[3]
14Hermano inmediatamente siguienteh2 + p//h2/following-sibling::*[1][self::p]
15Todos los hermanos siguientesh2 ~ span//h2/following-sibling::span
16Varias seleccionesh1, h2//h1 | //h2
17Tarjeta que contiene un elemento concretodiv.product:has(> span.discount)//span[@class="discount"]/..
18Botón con texto exactoNo está en el estándar//button[text()="Añadir al carrito"]
19Un ancestro concreto de un elementoNo está en el estándar//span[@class="price"]/ancestor::div[@data-sku][1]
20Valor junto a una etiquetaNo está en el estándar//th[normalize-space()="Stock"]/following-sibling::td[1]

Algunos detalles de la tabla provocan errores frecuentes:

  • Fila 3: //*[contains(@class, "product")] parece más corto, pero también selecciona clases como products u old-product. La expresión larga de la tabla coincide solo con la clase exacta product.
  • Fila 10: XPath 1.0 no tiene ends-with(); los últimos caracteres se comparan con substring. El número es uno menos que la longitud de la extensión buscada (.webp tiene cinco caracteres, - 4).
  • Fila 11: li:first-child y li:first-of-type son distintos. El primero selecciona un li que es el primer hijo de su padre; si el primer hijo es otra etiqueta, no selecciona nada.
  • Fila 14: //h2/following-sibling::p[1] significa «el primer p siguiente» y coincide aunque haya otros elementos en medio. El h2 + p de CSS solo coincide si el elemento justo después de h2 es un p.

Para devolver valores de atributos y nodos de texto, XPath usa //a/@href y //h2/text(). La librería parsel de Python añade a CSS las extensiones no estándar a::attr(href) y h2::text para estos trabajos.

Lo que XPath puede hacer y CSS no

Selección por texto

En una página de tienda online, el botón «Añadir al carrito» y el botón «Sin stock» pueden llevar la misma clase. Lo único que los distingue es su texto:

text
//button[text()="Añadir al carrito"]
//span[contains(normalize-space(.), "Descuento")]

text() solo mira el nodo de texto directo del elemento; no coincide si el texto está repartido entre etiquetas anidadas. normalize-space(.) une todo el texto del elemento y quita los espacios del principio y del final, así que suele ser más fiable.

El CSS estándar no tiene selección por texto. Algunas herramientas ofrecen sus propias extensiones: la opción has_text y :has-text() en Playwright, y :-soup-contains() en BeautifulSoup. Estas extensiones solo funcionan en esa herramienta.

Subir al padre y al hermano anterior

Si en una tarjeta de producto solo el campo del precio tiene una clase estable, tienes que partir del precio para llegar a la tarjeta completa:

text
//span[@class="price"]/..
//span[@class="price"]/ancestor::div[@data-sku][1]

.. selecciona el padre directo y ancestor::, un ancestro a cualquier nivel superior. [1] toma el ancestro más cercano.

Estructuras etiqueta-valor

Las tablas de especificaciones de producto y las listas de definiciones están entre las estructuras con las que más se topa el scraping. Qué fila va en qué orden varía según el producto; lo que se mantiene fijo es el texto de la etiqueta:

text
//th[normalize-space()="Stock"]/following-sibling::td[1]
//dt[normalize-space()="Garantía"]/following-sibling::dd[1]

Estas expresiones encuentran el valor junto a «Stock» esté en la fila de la tabla que esté. Para hacer lo mismo con CSS tendrías que extraer todas las filas y recorrerlas en Python.

Usar CSS y XPath en Python

Python tiene tres librerías habituales, y su soporte de selectores es distinto.

BeautifulSoup solo admite selectores CSS (select y select_one); no tiene soporte de XPath:

python
from bs4 import BeautifulSoup

soup = BeautifulSoup(html, "html.parser")
products = []
for card in soup.select("div.product"):
    products.append({
        "sku": card.get("data-sku"),
        "name": card.select_one("h2").get_text(strip=True),
        "price": card.select_one(".price").get_text(strip=True),
        "link": card.select_one("a[href]")["href"],
    })

lxml ejecuta XPath 1.0 directamente y puede devolver el resultado como texto con funciones como string():

python
from lxml import html as lxml_html

tree = lxml_html.fromstring(html)
stock = tree.xpath('string(//th[normalize-space()="Stock"]/following-sibling::td[1])')
discounted_sku = tree.xpath('//span[@class="discount"]/ancestor::div[@data-sku][1]/@data-sku')

parsel (la librería de selectores de Scrapy) ofrece ambos lenguajes en el mismo objeto, encadenables entre sí. Puedes encontrar las tarjetas con CSS y usar XPath dentro de una tarjeta:

python
from parsel import Selector

page = Selector(text=html)
for card in page.css("div.product"):
    name = card.css("h2::text").get()
    link = card.css("a::attr(href)").get()
    available = card.xpath('.//button[text()="Añadir al carrito"]').get() is not None
    print(name, link, available)

Al encadenar hay que tener en cuenta que una expresión XPath que se ejecuta dentro de una tarjeta empieza por .//. Si escribes //button, la búsqueda recorre todo el documento en lugar de la tarjeta, y encuentras para cada tarjeta el primer botón de la página.

Para decidir con qué lenguaje y librería trabajar, consulta Web scraping: ¿JavaScript o Python?.

Selectores en navegadores headless

En páginas cuyo contenido carga con JavaScript, los selectores se usan dentro de una herramienta de automatización de navegador. Playwright admite ambos lenguajes y trata automáticamente como XPath una expresión que empieza por //:

python
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch()
    page = browser.new_page()
    page.goto("https://example.com/deals")

    prices = page.locator("div.product .price").all_inner_texts()
    stock = page.locator('xpath=//th[normalize-space()="Stock"]/following-sibling::td[1]').inner_text()
    addable = page.locator("div.product", has_text="Añadir al carrito")

    print(prices, stock, addable.count())
    browser.close()

En Selenium, los mismos trabajos se hacen con By.CSS_SELECTOR y By.XPATH; explicamos la instalación y la configuración de proxy en Cómo usar un proxy con Selenium y Cómo usar un proxy con SeleniumBase. Para saber si una página necesita realmente un navegador, consulta Páginas estáticas y dinámicas.

¿Importa realmente la diferencia de rendimiento?

La afirmación «los selectores CSS son más rápidos que XPath» aparece a menudo en internet. Depende del entorno:

  • En el navegador, querySelectorAll usa directamente el motor de selectores optimizado del navegador; XPath con document.evaluate suele ser más lento. La diferencia se nota en páginas muy grandes y con consultas repetidas miles de veces.
  • En Python con lxml y parsel, los selectores CSS se traducen en su mayoría a XPath por detrás y se ejecutan como XPath. Así que ambos lenguajes usan el mismo motor y la diferencia desaparece en gran parte.
  • En BeautifulSoup, la velocidad de selección depende más del parser usado (html.parser o lxml) que del lenguaje del selector.

Lo que realmente decide es cómo está escrito el selector. //*[contains(@class, "price")], que recorre todo el documento, hace mucho más trabajo que una expresión que primero encuentra la tarjeta y luego el precio dentro de ella. Y todo esto suele ser poco frente al tiempo de descarga de la página desde la red o de carga en un navegador headless. Tratamos el verdadero cuello de botella de un scraper en Concurrencia y paralelismo.

¿Cómo evitar selectores frágiles?

El motivo más común por el que se rompen los scrapers no es un bloqueo, sino un cambio en el diseño del sitio. Para que un selector sobreviva a pequeños cambios de la página:

  • Evita los nombres de clase generados automáticamente. Clases como css-1x9k2ab o sc-bdVaJa pueden cambiar con cada versión.
  • No escribas rutas absolutas. Una expresión copiada del navegador como /html/body/div[3]/div[2]/ul/li[4]/span se rompe en cuanto se añade un solo banner a la página.
  • Ánclate al significado, no a la posición. No «el tercer span», sino «el span con la clase price» o «la celda junto al encabezado Stock».
  • Prefiere atributos estables. Atributos como data-sku, data-testid, itemprop y aria-label son independientes del diseño visual, así que cambian menos.
  • Mira primero los datos estructurados. Muchas páginas de producto llevan el nombre, el precio y el stock en formato schema.org dentro de <script type="application/ld+json">. Leer esos datos es mucho más robusto que analizar el HTML visual.
  • Selecciona primero el contenedor y luego el campo. Encontrar una tarjeta una vez y buscar los campos dentro de ella evita mezclar campos.
  • Trata un resultado vacío como un error. En lugar de escribir en silencio un valor vacío cuando un selector no encuentra nada, regístralo y lanza una alerta; notarás el cambio de diseño el primer día.

Que los selectores vuelvan vacíos no siempre es un cambio de diseño; a veces es otra página devuelta por la protección de bots y a veces contenido que carga después con JavaScript. Para el diagnóstico, consulta la lista de Cómo hacer web scraping sin que te bloqueen.

¿Cómo se prueba un selector en el navegador?

Probar un selector en el navegador antes de escribirlo en el código ahorra tiempo. En las herramientas para desarrolladores (F12) de Chrome, Edge y Firefox:

  • $$("div.product .price") en la consola devuelve como array todos los elementos que coinciden con el selector CSS.
  • $x('//th[normalize-space()="Stock"]/following-sibling::td[1]') en la consola ejecuta una expresión XPath.
  • Ctrl + F en el panel Elements abre un cuadro de búsqueda que acepta texto plano, selectores CSS y XPath, y resalta uno a uno los elementos que coinciden.

Dos advertencias: la página que ves en el navegador es el estado después de ejecutar JavaScript. Si tu scraper solo obtiene el HTML que devuelve el servidor a través de un cliente HTTP, un selector que funciona en el navegador puede volver vacío en el código; compruébalo viendo el código fuente (Ctrl + U). Segunda, la función «Copy XPath» del navegador suele generar rutas absolutas y frágiles; úsalas como punto de partida y simplifícalas.

Casos de uso

  • Seguimiento de precios: tarjetas de producto con CSS y campos etiqueta-valor como «Stock» con XPath. La configuración está en nuestra página de solución de seguimiento de precios.
  • Recogida de catálogos: primero JSON-LD; si falta, selectores CSS con atributos data-* estables. La configuración general de la recogida de datos está en nuestra página de solución de extracción de datos.
  • Comparadores: un archivo de definición de selectores por sitio para las distintas estructuras; XPath para los campos basados en texto.
  • Automatización de pruebas: selectores CSS con atributos data-testid en tu propia aplicación; pruebas que no se ven afectadas por los cambios de diseño.
  • Recogida regular de muchas páginas: mientras los selectores se mantengan estables, el trabajo se reduce a repartir las peticiones; puede usarse un Proxies rotativos para distintas IP de salida a través de una sola dirección.

Guía de decisión

Tu necesidadRecomendación
Seleccionar por clase, id o atributoSelector CSS
Usas BeautifulSoupSelector CSS (sin soporte de XPath)
Encontrar un elemento por su textoXPath
Pasar de un elemento a su padre o hermano anteriorXPath
Tablas etiqueta-valor y listas de definicionesXPath
Devolver directamente un valor de atributoXPath /@attr o parsel ::attr()
Ambos en el mismo proyectoparsel
Navegador headlessLocators de Playwright, primero CSS
Robustez ante cambios de diseñoAtributos data-* o JSON-LD

Preguntas frecuentes

¿Aprendo selectores CSS o XPath?

Aprende primero los selectores CSS; son más cortos, más comunes y también útiles en desarrollo web. Cuando en scraping necesites seleccionar por texto y subir a los padres, basta con aprender los ejes y funciones de XPath. Con librerías como parsel, que usan ambos, el cambio es sencillo.

¿BeautifulSoup admite XPath?

No. BeautifulSoup solo admite selectores CSS. Si necesitas XPath, puedes analizar el mismo HTML con lxml o parsel.

¿CSS puede seleccionar por texto?

En CSS estándar, no. Algunas herramientas ofrecen sus propias extensiones: has_text y :has-text() en Playwright, y :-soup-contains() en BeautifulSoup. Estas extensiones solo funcionan en esa herramienta y no son portables.

¿Por qué no funciona el XPath copiado del navegador?

La ruta que genera el navegador suele ser absoluta y se basa en la página después de ejecutar JavaScript. Esa estructura puede no existir en el HTML que devuelve el servidor al principio, o un pequeño cambio de diseño puede romper la ruta. Simplifica la ruta para que empiece en una clase, un id o un atributo estables.

¿Qué diferencia hay entre text() y normalize-space()?

text() devuelve los nodos de texto directos del elemento y deja los espacios en blanco tal cual. normalize-space(.) une todo el texto del elemento, incluidas las etiquetas anidadas, quita los espacios del principio y del final y reduce los espacios interiores a uno. Para comparar textos, normalize-space() suele ser más fiable.

¿Por qué mis selectores vuelven vacíos a veces?

Hay tres motivos habituales: el diseño de la página ha cambiado, el contenido se carga después con JavaScript o el sitio devuelve una página de verificación o de error en lugar de la esperada. Guardar el HTML de la respuesta en un archivo y probar el selector sobre ese archivo es la forma más rápida de distinguir los tres casos.

En resumen

Los selectores CSS son cortos, legibles y compatibles con cualquier herramienta; deberían ser la opción por defecto para seleccionar por etiqueta, clase, id y atributo. XPath hace falta para lo que CSS no puede hacer, como seleccionar por texto, subir a padres y hermanos anteriores y trabajar con estructuras etiqueta-valor; en navegadores y lxml se limita a la versión 1.0. En la mayoría de los trabajos de scraping, la diferencia de rendimiento es insignificante frente al tiempo de red; lo que realmente importa es escribir selectores que sobrevivan a los cambios de diseño. Encontrarás tipos de proxy adecuados para tu trabajo de recogida de datos en nuestros servicios de proxy.