Volver al blog

XPath vs Selectores CSS: ¿Cuál usar para el Web Scraping?

Emily Chen
Emily Chen

Advanced Data Extraction Specialist

10-Sep-2026

TL;DR:

  • CSS y XPath devuelven resultados idénticos para extracción ordinaria: los mismos veinte títulos de libros regresaron de article.product_pod h3 a::attr(title) y //article[@class='product_pod']/h3/a/@title en la misma página.
  • XPath es el único de los dos que se desplaza hacia arriba en el árbol. parent::, ancestor::, preceding-sibling:: y following-sibling:: coincidieron con 20 nodos en esa página; CSS no tiene equivalente para ninguno de ellos.
  • "CSS es más rápido" es un hecho de navegador, no un hecho de Python. En parsel, la misma extracción costó 487.1 ms por CSS contra 309.1 ms por XPath en 2,000 iteraciones, porque cssselect compila cada consulta CSS en XPath antes de ejecutarla.
  • a:contains('Sharp') devuelve una coincidencia en parsel y lanza SyntaxError dentro de una página real de Chrome, por eso un selector puede funcionar en un scraper y fallar en DevTools.
  • Ninguno de los dos lenguajes importa cuando el marcado nunca llega; un selector solo puede consultar un DOM que realmente fue construido.
  • Ambos lenguajes leen los bytes que su capa de recuperación decodificó, por lo que una página servida sin un charset puede devolver £47.82 donde el navegador muestra £47.82.
  • Ejecuta cualquiera de las sintaxis contra páginas completamente renderizadas en el plan gratuito de Scrapeless.

Abre el inspector en cualquier listado de productos, copia el selector que Chrome te ofrece, pégalo en un scraper de Python y generalmente funciona. Los casos interesantes son aquellos donde no funciona — donde el selector es válido en un motor y un error de sintaxis en otro, o donde el CSS ordenado que escribiste resulta ser más lento que el XPath que evitaste.

Los dos lenguajes se superponen en gran medida, por lo que la comparación útil se encuentra en los márgenes: lo que cada uno puede expresar, lo que cada uno cuesta y qué motor está realmente ejecutando la consulta.

Cada medida a continuación proviene de una página pública construida para la práctica de scraping — una lista de categorías de veinte libros en books.toscrape.com — analizada con lxml 6.0.2, cssselect 1.3.0 y parsel 1.10.0.

La Misma Consulta en Ambos Lenguajes

Para los objetivos comunes, los dos lenguajes son una traducción directa el uno del otro. La tabla a continuación empareja las consultas que seleccionan conjuntos de nodos idénticos en la página de prueba.

Objetivo CSS XPath
Cualquier etiqueta article //article
Clase .product_pod //*[contains(concat(' ',normalize-space(@class),' '),' product_pod ')]
ID #messages //*[@id='messages']
Descendiente article h3 a //article//h3//a
Hijo directo div > span //div/span
Valor de atributo a[title] //a[@title]
Hijo en N li:nth-child(2) //li[2]
Texto de atributo a::attr(href) //a/@href

Tres pares de esa tabla, ejecutados contra la página en vivo, devolvieron conjuntos de nodos coincidentes:

python Copy
from parsel import Selector
import requests

url = "https://books.toscrape.com/catalogue/category/books/mystery_3/index.html"
response = requests.get(url, timeout=30)
response.raise_for_status()
sel = Selector(text=response.content.decode("utf-8"))

pairs = [
    ("article.product_pod h3 a::attr(href)", "//article[@class='product_pod']/h3/a/@href"),
    ("p.star-rating::attr(class)",           "//p[contains(@class,'star-rating')]/@class"),
    ("div.image_container img::attr(alt)",   "//div[@class='image_container']//img/@alt"),
]
for css_query, xpath_query in pairs:
    css_hits = sel.css(css_query).getall()
    xpath_hits = sel.xpath(xpath_query).getall()
    print(len(css_hits), len(xpath_hits), css_hits == xpath_hits)

Cada par impresó 20 20 True. Donde ambos lenguajes pueden expresar el objetivo, la elección es legibilidad, no capacidad.

Lo Que Solo XPath Puede Expresar

CSS selecciona hacia abajo. Se mueve de un ancestro a un descendiente y nunca hacia atrás, por lo que una regla puede decir "el precio dentro de esta tarjeta" pero no "la tarjeta que contiene este precio".

XPath lleva ejes, y cuatro de ellos no tienen equivalente CSS en absoluto. Cada uno coincidió con 20 nodos en la página de prueba:

python Copy
axes = [
    ("parent::",            "//p[@class='price_color']/parent::div/@class"),
    ("ancestor::",          "//h3/ancestor::article/@class"),
    ("preceding-sibling::", "//div[@class='product_price']/preceding-sibling::h3/a/@title"),
    ("following-sibling::", "//h3/following-sibling::div[@class='product_price']//p[@class='price_color']/text()"),
]
for label, query in axes:
    hits = sel.xpath(query).getall()
    print(f"{label:20s} {len(hits):2d} hits   {hits[0]!r}")
text Copy
parent::             20 hits   'product_price'
ancestor::           20 hits   'product_pod'
preceding-sibling::  20 hits   'Sharp Objects'
following-sibling::  20 hits   '£47.82'

Ese último es el patrón que vale la pena conservar. "Encuentra el encabezado, luego toma el precio que le sigue" es una relación de hermano, y expresarlo en CSS significa seleccionar el precio por separado y volver a unir las dos listas por índice — lo que produce silenciosamente pares incorrectos en el momento en que una tarjeta carece de un precio.

La especificación del nivel 4 de selectores de W3C define la gramática CSS, y el recorrido ascendente está ausente de ella por diseño: el lenguaje fue construido para el estilo, donde un renderizador resuelve reglas de arriba hacia abajo. La recomendación XPath 1.0 define trece ejes porque fue construido para abordar nodos arbitrarios en un árbol de documentos.

La División :contains

La coincidencia de texto es donde los dos lenguajes divergen de una manera que produce informes de errores confusos.

XPath siempre ha tenido contains(). CSS tenía una :contains() pseudo-clase en un primer borrador y fue eliminada antes de que la especificación se estabilizara. El problema es que el cssselect de Python aún lo implementa.

python Copy
print(len(sel.css("a:contains('Sharp')").getall()))       # 1
print(len(sel.xpath("//a[contains(., 'Sharp')]").getall()))  # 1

Ambos imprimen 1. Ahora las mismas dos consultas dentro de una página de navegador real, evaluadas a través de los propios motores del DOM:

python Copy
import os
from urllib.parse import urlencode
from playwright.sync_api import sync_playwright

JS = """() => {
  const out = {};
  try { out.css_class = document.querySelectorAll('article.product_pod h3 a').length; }
  catch (e) { out.css_class = 'ERR ' + e.name; }
  try { out.css_contains = document.querySelectorAll("a:contains('Sharp')").length; }
  catch (e) { out.css_contains = 'ERR ' + e.name + ': ' + e.message.slice(0, 60); }
  const xp = (q) => document.evaluate(q, document, null,
    XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null).snapshotLength;
  out.xpath_class    = xp("//article[@class='product_pod']/h3/a");
  out.xpath_contains = xp("//a[contains(., 'Sharp')]");
  out.xpath_parent   = xp("//p[@class='price_color']/parent::div");
  out.xpath_ancestor = xp("//h3/ancestor::article");
  return out;
}"""

url = "https://books.toscrape.com/catalogue/category/books/mystery_3/index.html"
endpoint = "wss://browser.scrapeless.com/api/v2/browser?" + urlencode({
    "token": os.environ["SCRAPELESS_API_KEY"], "sessionTTL": 300, "proxyCountry": "US",
})
with sync_playwright() as p:
    browser = p.chromium.connect_over_cdp(endpoint)
    page = browser.new_page()
    page.goto(url, wait_until="domcontentloaded")
    for key, value in page.evaluate(JS).items():
        print(f"{key:16s} {value}")
    browser.close()
text Copy
css_class        20
css_contains     ERR SyntaxError: Failed to execute 'querySelectorAll' on 'Document': 'a:conta
xpath_class      20
xpath_contains   1
xpath_parent     20
xpath_ancestor   20

La coincidencia de texto de CSS lanza un error. La coincidencia de texto de XPath devuelve su único nodo. Así que un selector que pasa pruebas en un scraper de Python genera un error de sintaxis en el momento en que alguien lo pega en DevTools para verificarlo — y lo contrario ocurre para cualquiera que valide selectores en la consola antes de enviarlos.
Dos cosas se desprenden de esa ejecución que vale la pena señalar. XPath en el navegador no está degradado: parent:: y ancestor:: resolvieron cada uno 20 nodos a través de la interfaz document.evaluate del DOM. Y :contains es una extensión de biblioteca en lugar de una característica del lenguaje, por lo que viaja solo tan lejos como lo hace la biblioteca.

Si un selector tiene que funcionar en ambos lugares, //a[contains(., 'Sharp')] es la forma portátil.

Cuál Es Realmente Más Rápido

La respuesta común es que CSS es más rápido. Eso es cierto en un navegador, donde querySelectorAll es un camino nativo rápido, y es al revés en Python.

En lxml no hay motor CSS. Cada consulta CSS se traduce en XPath mediante cssselect y el motor XPath la ejecuta, lo que la documentación de selectores de Scrapy afirma directamente. Escribir CSS compra el paso de traducción.

python Copy
import time

N = 2000
start = time.perf_counter()
for _ in range(N):
    sel.css("article.product_pod h3 a::attr(title)").getall()
css_ms = (time.perf_counter() - start) * 1000

start = time.perf_counter()
for _ in range(N):
    sel.xpath("//article[@class='product_pod']/h3/a/@title").getall()
xpath_ms = (time.perf_counter() - start) * 1000

print(f"CSS   {css_ms:8.1f} ms  ({css_ms / N * 1000:6.1f} us/call)")
print(f"XPath {xpath_ms:8.1f} ms  ({xpath_ms / N * 1000:6.1f} us/call)")
text Copy
CSS      487.1 ms  ( 243.5 us/call)
XPath    309.1 ms  ( 154.5 us/call)

CSS costó 1.58 veces el tiempo de XPath para una salida idéntica. Imprimir la traducción muestra dónde va:

python Copy
from cssselect import GenericTranslator
print(GenericTranslator().css_to_xpath("article.product_pod h3 a"))
text Copy
descendant-or-self::article[@class and contains(concat(' ', normalize-space(@class), ' '), ' product_pod ')]/descendant-or-self::*/h3/descendant-or-self::*/a

El //article[@class='product_pod']/h3/a escrito a mano compara un atributo. La forma generada normaliza el espacio en blanco y rellena la lista de clases en cada nodo candidato, porque tiene que ser correcta para elementos que llevan varias clases. Ese predicado defensivo es el 1.58x.

Pon eso en proporción antes de optimizar por ello: la brecha es de aproximadamente 89 microsegundos por llamada en una página de 50 KB. Una sola solicitud HTTP cuesta miles de veces más. El lenguaje de selección no es donde se va el tiempo de un scraper, y la legibilidad suele ser el mejor intercambio: el número importa solo en un bucle de análisis caliente sobre HTML en caché.

La Trampa Que Comparten Ambos Lenguajes

Un selector devuelve lo que tu capa de obtención decodificó. Cuando un servidor envía text/html sin charset, las solicitudes retroceden a ISO-8859-1 bajo las reglas de tipo de medio de la especificación de semántica HTTP, mientras que los bytes en la red son UTF-8:

python Copy
import requests
from parsel import Selector

url = "https://books.toscrape.com/catalogue/category/books/mystery_3/index.html"
response = requests.get(url, timeout=30)
response.raise_for_status()

print(response.headers.get("content-type"))
print(response.encoding)
print(response.apparent_encoding)
print(Selector(text=response.text).css("p.price_color::text").get())
print(Selector(text=response.content.decode("utf-8")).css("p.price_color::text").get())
text Copy
text/html
ISO-8859-1
utf-8
'£47.82'
'£47.82'

El selector estuvo bien ambas veces. Decodificar response.content explícitamente, en lugar de confiar en response.text, es lo que hace que el precio extraído sea usable, y ningún cambio en el lenguaje de selector lo soluciona.

Probar un selector contra una página que se renderiza del lado del cliente necesita un navegador real. El plan gratuito de Scrapeless cubre suficientes sesiones para probar ambas sintaxis contra un DOM en vivo.

Donde Ningún Lenguaje Ayuda

Ambos lenguajes consultan un DOM. Ninguno construye uno.

Cuando un listado se renderiza del lado del cliente, el HTML que recibe un cliente HTTP simple contiene la shell y no los registros, por lo que un selector correcto devuelve cero nodos y parece un error de selector. La solución está aguas arriba del selector: renderiza la página primero, luego consúltala. El Navegador de Scraping de Scrapeless expone un navegador en la nube sobre CDP, por lo que la misma página que el navegador ensambló es contra la cual se ejecuta tu selector, que es exactamente cómo se capturaron los números en el navegador anteriormente.

python Copy
import os
from urllib.parse import urlencode
from playwright.sync_api import sync_playwright

url = "https://books.toscrape.com/catalogue/category/books/mystery_3/index.html"
cdp_endpoint = "wss://browser.scrapeless.com/api/v2/browser?" + urlencode({
    "token": os.environ["SCRAPELESS_API_KEY"],
    "sessionTTL": 300,
    "proxyCountry": "US",
})

with sync_playwright() as p:
    browser = p.chromium.connect_over_cdp(cdp_endpoint)
    page = browser.new_page()
    page.goto(url, wait_until="domcontentloaded")
    titles = page.eval_on_selector_all(
        "article.product_pod h3 a", "els => els.map(e => e.title)"
    )
    print(len(titles), titles[0])
    browser.close()
text Copy
20 Sharp Objects

Una vez que existe el DOM, la pregunta del selector vuelve a ser una cuestión de estilo. Para una guía sobre la capa de selección en sí, nuestra guía de parsel cubre ambos dialectos sobre un árbol respaldado por lxml, y los precios enumeran lo que cuesta una sesión de renderizado.

Elegir CSS Cuándo, Elegir XPath Cuándo

Situación Opta por
Coincidencia de clase, id, atributo o descendiente CSS
El selector se comparte con compañeros del front-end CSS
La regla también debe ejecutarse en DevTools o querySelectorAll CSS o XPath portátil
Seleccionando un contenedor por lo que contiene XPath
Emparejando una etiqueta con el valor que la sigue XPath
Coincidencia en texto visible XPath
Subiendo a un ancestro XPath
Analizando XML, RSS o un sitemap XPath
Un bucle caliente sobre HTML en caché en lxml XPath

Elige CSS cuando el objetivo sea direccionable hacia abajo y la consulta será leída por personas que escriben hojas de estilo. Es más corto y, en la página de prueba, seleccionó cada campo ordinario sin pérdida.

Elige XPath cuando la relación que necesitas sea estructural en lugar de jerárquica: hacia arriba en el árbol, a través de hermanos o clave en texto. También es la opción honesta por defecto dentro de lxml y parsel, donde es lo que realmente se ejecuta.
La mayoría de los scrapers en funcionamiento mezclan los dos por campo en lugar de elegir un lado, y parsel acepta ambos contra el mismo árbol, por lo que la decisión es por selector en lugar de por proyecto.

Conclusión

CSS y XPath responden la misma pregunta para los casos ordinarios, y los veinte títulos regresaron idénticos de ambos. Las diferencias que importan son más estrechas de lo que sugiere el marco habitual: XPath puede recorrer hacia arriba y coincidir con texto, CSS no puede; CSS es más rápido en un navegador y más lento en lxml, donde se compila a XPath primero; y :contains funciona en un motor mientras que falla en el otro, que es la fuente de la mayoría de la confusión de "funciona en mi scraper, no en la consola".

Elige por selector, mantén la forma portable cuando una consulta tiene que ejecutarse en ambos lugares, y decodifica la respuesta antes de culpar a cualquiera de los dos lenguajes por un carácter distorsionado.

¿Listo para probar selectores contra páginas que se renderizan antes de que las consultes? Comienza con el plan gratuito de Scrapeless y apunta cualquiera de las sintaxis a un DOM en vivo.

FAQ

P: ¿Es XPath más rápido que los selectores CSS?

Depende del motor. En lxml y parsel, XPath es más rápido porque CSS se compila en XPath antes de la ejecución; la diferencia medida aquí fue de 309.1 ms contra 487.1 ms en 2,000 iteraciones de la misma extracción. En un navegador, querySelectorAll es una ruta nativa rápida y CSS gana. De cualquier manera, la diferencia es microsegundos por llamada, muy por debajo del costo de la solicitud HTTP.

P: ¿Pueden los selectores CSS coincidir con el texto del elemento?

No en un navegador. document.querySelectorAll("a:contains('x')") lanza una SyntaxError, porque :contains() fue eliminado antes de que la especificación de Selectores se estabilizara. La selección CSS de Python aún lo implementa, por lo que la misma consulta funciona en parsel. Para un selector que se comporta de manera idéntica en ambos, usa //a[contains(., 'x')].

P: ¿Puede un selector CSS seleccionar un elemento padre?

No. CSS selecciona hacia abajo únicamente, por lo que no hay parent:: o ancestor:: equivalente. XPath maneja ambos, y en la página de prueba //h3/ancestor::article coincidió con las 20 tarjetas. La pseudo-clase :has() te permite filtrar un elemento por sus descendientes, lo que cubre parte de la misma intención, pero aún devuelve el elemento exterior en lugar de recorrer hacia arriba desde uno interno.

P: ¿Por qué mi selector funciona en DevTools pero no devuelve nada en Python?

Dos causas habituales. La página renderiza su contenido con JavaScript, por lo que el HTML que recibió tu cliente HTTP nunca contuvo los nodos que el navegador construyó más tarde; el selector es correcto y el DOM no está allí. O el selector utiliza una extensión solo para navegador o solo de biblioteca. Compara el cuerpo de respuesta sin procesar con lo que muestra el inspector antes de cambiar el selector.

P: ¿Debería usar CSS o XPath en Scrapy?

Ambos, por campo. Scrapy expone response.css() y response.xpath() sobre el mismo selector de parsel, y se pueden encadenar juntos. Usa CSS para coincidencias de clase y atributo simples, y cambia a XPath para coincidencias de texto o recorridos hacia arriba en lugar de retorcer una regla CSS para ajustarse.

P: ¿Funcionan los selectores XPath en todos los navegadores?

Sí, a través de document.evaluate en lugar de querySelectorAll. Es una interfaz DOM separada, y los ejes están completamente soportados; parent:: y ancestor:: devolvieron cada uno 20 nodos en la ejecución anterior. El helper $x() en Chrome DevTools envuelve la misma interfaz para uso en consola.

En Scrapeless, solo accedemos a datos disponibles públicamente y cumplimos estrictamente con las leyes, regulaciones y políticas de privacidad del sitio web aplicables. El contenido de este blog es sólo para fines de demostración y no implica ninguna actividad ilegal o infractora. No ofrecemos garantías y renunciamos a toda responsabilidad por el uso de la información de este blog o enlaces de terceros. Antes de realizar cualquier actividad de scraping, consulte a su asesor legal y revise los términos de servicio del sitio web de destino u obtenga los permisos necesarios.

Artículos más populares

Catalogar