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/@srctext()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
| Criterio | Selector CSS | XPath |
|---|---|---|
| Legibilidad | Corto, conocido por los desarrolladores web | Largo, curva de aprendizaje más pronunciada |
| Dirección | Hacia abajo y hacia delante (en parte hacia fuera con :has()) | Cualquier dirección: padre, hermano anterior, ancestro |
| Selección por texto | No está en el estándar | Sí: text(), contains() |
| Devolver valores de atributos | No (con extensiones de librerías) | Sí: /@href |
| Devolver nodos de texto | No (con extensiones de librerías) | Sí: /text() |
| Coincidencia de clase | .product, exacta y corta | contains(@class, ...) requiere cuidado |
| Soporte en navegadores | querySelectorAll | document.evaluate (XPath 1.0) |
| Soporte en Python | BeautifulSoup, lxml (cssselect), parsel | lxml, parsel; BeautifulSoup no lo admite |
| Navegadores headless | Playwright, Puppeteer, Selenium | Playwright, Puppeteer, Selenium |
| Uso típico | Extraer listas por clase y atributo | Tablas 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 CSS | XPath |
|---|---|---|---|
| 1 | Todos los elementos div | div | //div |
| 2 | Por id | #list | //*[@id="list"] |
| 3 | Por clase | .product | //*[contains(concat(" ", normalize-space(@class), " "), " product ")] |
| 4 | Hijo directo | ul > li | //ul/li |
| 5 | Descendiente a cualquier profundidad | div span | //div//span |
| 6 | Tiene el atributo | a[href] | //a[@href] |
| 7 | El valor del atributo es igual a | input[name="q"] | //input[@name="q"] |
| 8 | El atributo empieza por | a[href^="https"] | //a[starts-with(@href, "https")] |
| 9 | El atributo contiene | a[href*="product"] | //a[contains(@href, "product")] |
| 10 | El atributo termina en | img[src$=".webp"] | //img[substring(@src, string-length(@src) - 4) = ".webp"] |
| 11 | Primer elemento | ul > li:first-of-type | //ul/li[1] |
| 12 | Último elemento | ul > li:last-of-type | //ul/li[last()] |
| 13 | Tercer elemento | ul > li:nth-of-type(3) | //ul/li[3] |
| 14 | Hermano inmediatamente siguiente | h2 + p | //h2/following-sibling::*[1][self::p] |
| 15 | Todos los hermanos siguientes | h2 ~ span | //h2/following-sibling::span |
| 16 | Varias selecciones | h1, h2 | //h1 | //h2 |
| 17 | Tarjeta que contiene un elemento concreto | div.product:has(> span.discount) | //span[@class="discount"]/.. |
| 18 | Botón con texto exacto | No está en el estándar | //button[text()="Añadir al carrito"] |
| 19 | Un ancestro concreto de un elemento | No está en el estándar | //span[@class="price"]/ancestor::div[@data-sku][1] |
| 20 | Valor junto a una etiqueta | No 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 comoproductsuold-product. La expresión larga de la tabla coincide solo con la clase exactaproduct. - Fila 10: XPath 1.0 no tiene
ends-with(); los últimos caracteres se comparan consubstring. El número es uno menos que la longitud de la extensión buscada (.webptiene cinco caracteres,- 4). - Fila 11:
li:first-childyli:first-of-typeson distintos. El primero selecciona unlique 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 primerpsiguiente» y coincide aunque haya otros elementos en medio. Elh2 + pde CSS solo coincide si el elemento justo después deh2es unp.
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:
//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:
//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:
//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:
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():
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:
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 //:
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,
querySelectorAllusa directamente el motor de selectores optimizado del navegador; XPath condocument.evaluatesuele 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.parserolxml) 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-1x9k2abosc-bdVaJapueden 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]/spanse 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 «elspancon la claseprice» o «la celda junto al encabezado Stock». - Prefiere atributos estables. Atributos como
data-sku,data-testid,itempropyaria-labelson 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 + Fen 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-testiden 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 necesidad | Recomendación |
|---|---|
| Seleccionar por clase, id o atributo | Selector CSS |
| Usas BeautifulSoup | Selector CSS (sin soporte de XPath) |
| Encontrar un elemento por su texto | XPath |
| Pasar de un elemento a su padre o hermano anterior | XPath |
| Tablas etiqueta-valor y listas de definiciones | XPath |
| Devolver directamente un valor de atributo | XPath /@attr o parsel ::attr() |
| Ambos en el mismo proyecto | parsel |
| Navegador headless | Locators de Playwright, primero CSS |
| Robustez ante cambios de diseño | Atributos 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.




