Nuestra postura es fácil de explicar: somos Geonode y vendemos proxies, lo cual no tiene nada que ver con la selección de clases. La única observación relevante es que los selectores basados en clases son los más frágiles, y esa fragilidad se percibe desde fuera exactamente como un problema de red: la solicitud se realiza con éxito, la página se carga, pero la extracción no devuelve nada o devuelve información errónea. Si estás depurando un rastreador que ha dejado de funcionar, comprueba si los nombres de las clases han cambiado antes de analizar cómo se ha obtenido la página. En un sitio web que utilice un proceso de compilación moderno, estos pueden cambiar con cada implementación.
El problema con el atributo «class»
class contiene una lista de tokens separados por espacios, y XPath no reconoce ese concepto de lista. Lo que ve es una sola cadena.
<div class="card featured large">...</div>
Para XPath, @class es la cadena "card featured large". No existe una forma integrada de preguntar «¿es card uno de los tokens?», que es precisamente la pregunta que un selector CSS responde de forma nativa con .card.
Esa laguna da lugar a los dos modos de fallo.
La coincidencia exacta es demasiado estricta:
//div[@class='card']
Solo coincide con class="card", sin nada más y sin espacios en blanco adicionales. No detecta class="card featured", class="featured card" ni class=" card ". En una página real, no detectará la mayor parte de lo que se busca.
La coincidencia de subcadenas es demasiado amplia:
//div[contains(@class, 'card')]
Coincide correctamente con class="card", y también con class="card-large", class="postcard", class="discard" y class="card-footer". En una página con nombres relacionados, devuelve un superconjunto de lo que has solicitado, y tu código toma el primer resultado sin avisar.
El segundo fallo es el más peligroso, porque produce resultados. Se devuelve algo, parece razonable, pero es el elemento equivocado.
La expresión idiomática correcta
La solución estándar añade espacios a ambos lados para que solo puedan coincidir tokens completos:
//div[contains(concat(' ', normalize-space(@class), ' '), ' card ')]
Léelo paso a paso:
**normalize-space(@class)
** elimina los espacios en blanco iniciales y finales y reduce las secuencias internas de espacios en blanco a un solo espacio. " card featured "
se convierte en "card featured"
.
**concat(' ', ..., ' ')
** envuelve el resultado en espacios, dando como resultado " card featured "
. Ahora cada token está delimitado por un espacio a ambos lados.
**contains(..., ' card ')
** busca el objetivo rodeado de espacios.
Compruébalo con los casos que fallaban en las versiones simplistas:
| Atributo de clase | Cadena rellenada | ¿Contiene «' card '
»? |
|---|---|---|
| card
| " card "
| Sí |
| card featured
| " card featured "
| Sí |
| featured card
| " featured card "
| Sí |
| card-large
| " card-large "
| No |
| discard
| " discard "
| No |
| postcard footer
| " postcard footer "
| No |
| card
| " card "
| Sí |
Correcto en todos los casos. El paso normalize-space()
no es meramente decorativo: sin él, class="card featured"
con un doble espacio produciría " card featured "
, que sigue conteniendo " card "
y resulta que funciona, pero class="card\nfeatured"
con un salto de línea no lo haría.
Es prolijo. Encuadre el código:
def has_class(name):
return f"contains(concat(' ', normalize-space(@class), ' '), ' {name} ')"
tree.xpath(f"//div[{has_class('card')}]")
const hasClass = name =>
`contains(concat(' ', normalize-space(@class), ' '), ' ${name} ')`;
Todo código que realice extracciones complejas con XPath acaba incluyendo alguna versión de este ayudante. Es mejor escribirlo una vez que equivocarse con el relleno en una de cada veinte expresiones.
Varias clases
Combínalo con «and
»:
//div[contains(concat(' ', normalize-space(@class), ' '), ' card ')
and contains(concat(' ', normalize-space(@class), ' '), ' featured ')]
Y ahí es donde la verbosidad se vuelve realmente pesada: son 140 caracteres para expresar lo que el CSS dice en div.card.featured
.
Para «cualquiera de las dos clases», utiliza «or
»:
//div[contains(concat(' ', normalize-space(@class), ' '), ' card ')
or contains(concat(' ', normalize-space(@class), ' '), ' tile ')]
Para «tiene esta clase pero no aquella»:
//div[contains(concat(' ', normalize-space(@class), ' '), ' card ')
and not(contains(concat(' ', normalize-space(@class), ' '), ' hidden '))]
Si hay dos o más condiciones de clase, plantéate seriamente si un selector CSS sería adecuado. div.card.featured:not(.hidden)
aplica la misma lógica con una quinta parte de los caracteres, y todas las bibliotecas de análisis sintáctico habituales admiten selectores CSS además de XPath. No hay ninguna regla que te obligue a elegir un único lenguaje para todo el archivo.
Nombres de clases generados y con hash
La complicación actual, que cambia las recomendaciones.
Muchas herramientas de compilación front-end generan nombres de clases con ámbito para evitar colisiones: CSS Modules, styled-components y diversas bibliotecas de CSS-in-JS. El resultado es similar a este:
<div class="ProductCard_container__3xK9p">...</div>
<div class="css-1x9dj2k">...</div>
. La parte del hash cambia cada vez que cambian los estilos del componente, lo que en la práctica significa que cambia en muchas implementaciones. Un selector anclado al nombre completo deja de funcionar sin previo aviso.
Hay tres formas de gestionarlo, por orden de preferencia.
Anclar al prefijo estable, cuando la herramienta lo genere:
//div[starts-with(@class, 'ProductCard_container__')]
Los módulos CSS suelen generar ComponentName_elementName__hash
, por lo que la parte anterior al doble guión bajo final es estable en todas las compilaciones. Esto funciona bien cuando es aplicable.
**Busca en su lugar un atributo «data-*
».** Muchas aplicaciones incluyen data-testid
, data-test
o atributos similares precisamente para que las herramientas automatizadas dispongan de un punto de anclaje estable:
//div[@data-testid='product-card']
Si existe uno, utilízalo. Es más estable que cualquier nombre de clase, ya que se ha elegido deliberadamente en lugar de generarse.
Fíjate en algo completamente distinto. Estructura en relación con un encabezado, contenido de texto o un tipo de elemento. A //h2[normalize-space()='Featured']/following-sibling::div[1]
no le importa cómo se llamen las clases.
Y en el caso totalmente opaco —css-1x9dj2k
sin ningún componente estable—, la selección basada en clases simplemente no es viable, y fingir lo contrario da lugar a un rastreador que falla cada semana. Busca datos estructurados en la página o la API a la que llama la propia página.
Orden de las clases y espacios en blanco
Dos aspectos que suelen confundir a la gente y sobre los que conviene ser preciso.
El orden de las clases en el atributo no tiene importancia. «class="card featured"
» y «class="featured card"
» son equivalentes para el navegador y para CSS. La técnica «padded-concat» gestiona ambos casos correctamente; la coincidencia exacta no gestiona ninguno de ellos de forma fiable. Si te encuentras escribiendo una expresión que depende del orden, es señal de que algo va mal.
Los espacios en blanco pueden ser cualquier cosa. Las tabulaciones y los saltos de línea son separadores válidos en un atributo de clase, y aparecen en el HTML formateado a mano:
<div class="card
featured">
normalize-space()
lo agrupa todo, por eso la convención lo incluye. Una expresión que utilice concat(' ', @class, ' ')
sin normalizar fallará con ese marcado, y el error es invisible en una página renderizada.
Las mayúsculas y minúsculas importan. Los nombres de clase distinguen entre mayúsculas y minúsculas en documentos HTML analizados como XHTML y se tratan sin distinción de mayúsculas y minúsculas en HTML en modo estándar para la coincidencia de CSS, pero la comparación de cadenas en XPath siempre distingue entre mayúsculas y minúsculas. Si una página mezcla Card
y card
, la sintaxis con relleno los tratará como diferentes. XPath 1.0 no dispone de la función «lower-case()
», por lo que la solución alternativa es translate()
con caracteres alfabéticos explícitos; en ese caso, la expresión se vuelve realmente ilegible y un selector CSS es claramente la mejor opción.
Obtener el antecesor o el descendiente de una clase
La selección de clases suele ser un medio más que un fin. Hay dos patrones que cubren la mayor parte de los casos.
De una clase a algo que se encuentra dentro de ella:
//div[contains(concat(' ',normalize-space(@class),' '),' card ')]//span[@class='price']
O bien, en código que ya contiene el elemento «card», utiliza una expresión relativa —y el punto inicial es imprescindible—:
for card in tree.xpath(f"//div[{has_class('card')}]"):
price = card.xpath(".//span[@class='price']/text()")
.//span
busca dentro de la tarjeta. //span
busca en todo el documento desde la raíz, lo que devuelve el primer precio de la página para cada tarjeta. Esto produce un resultado uniforme, plausible, pero erróneo, y es uno de los errores más comunes en el código de extracción.
Desde un elemento hasta su contenedor:
//span[@class='price']/ancestor::div[contains(concat(' ',normalize-space(@class),' '),' card ')][1]
El [1]
es importante: ancestor::
es un eje inverso, por lo que la posición 1 es el antepasado coincidente más cercano, en lugar del más externo. Sin él, se obtienen todos los antepasados coincidentes, y con contenedores anidados eso rara vez es lo que se desea.
Notas específicas de las bibliotecas
La sintaxis es la misma en todas partes; la API que la rodea no lo es, y algunas diferencias entre bibliotecas provocan una confusión que se podría evitar.
lxml (Python). Admite ambos lenguajes, y cssselect
es la opción más práctica para el trabajo en clase:
from lxml import html
tree = html.fromstring(source)
tree.cssselect('div.card.featured') # clear
tree.xpath(f"//div[{has_class('card')}]") # when inside a larger expression
cssselect
traduce CSS a XPath internamente, por lo que ambos son equivalentes en cuanto a las capacidades de los selectores que admiten. Ten en cuenta que se trata de un paquete independiente de lxml y que es necesario instalarlo.
BeautifulSoup (Python). No admite XPath en absoluto, un hecho que sorprende a quienes provienen de otros ecosistemas. Ofrece select()
para selectores CSS y su propia API find_all(class_='card')
, que realiza la coincidencia de tokens de forma nativa. Si necesitas XPath específicamente, necesitarás lxml.
Selenium. Acepta tanto By.XPATH
como By.CSS_SELECTOR
, y utiliza el motor del navegador para cada uno. Eso significa que solo admite XPath 1.0, y que la compatibilidad con CSS depende de lo que admita el navegador, incluyendo :has()
.
Playwright. Detecta automáticamente el tipo de localizador a partir de la cadena, por lo que tanto page.locator('div.card')
como page.locator('//div[@id="x"]')
funcionan sin necesidad de prefijo. También ofrece localizadores basados en texto —page.getByText()
, page.getByRole()
— que cubren gran parte de lo que antes requería la coincidencia de texto de XPath, y son más legibles que cualquiera de los dos lenguajes.
Scrapy. Proporciona .css()
y .xpath()
en los selectores, y se pueden encadenar, lo cual resulta realmente útil:
for card in response.css('div.card'):
price = card.xpath(".//dt[normalize-space()='Price']/following-sibling::dd[1]/text()").get()
CSS para la pasada estructural, XPath para la búsqueda de etiquetas, en la misma cadena de expresiones. Fíjate en el «.
» inicial del XPath: los selectores encadenados de Scrapy tienen la misma trampa de «raíz frente a relativo» que todo lo demás.
Consola del navegador. $x('//div[@class="card"]')
evalúa XPath en las herramientas de desarrollo de Chrome y Firefox, y document.querySelectorAll('div.card')
gestiona CSS. Merece la pena dedicar diez segundos a probar una expresión aquí antes de incluirla en el código, con la salvedad de que el DOM del navegador es posterior a JavaScript y el de tu analizador no lo es: una expresión que funcione en la consola puede que no encuentre nada en el HTML sin procesar.
Cuándo utilizar CSS en su lugar
Dicho sin rodeos, porque para esta tarea concreta la respuesta suele ser sí.
Utiliza CSS cuando tus criterios sean clases. div.card, div.card.featured, div.card:not(.hidden): todas ellas son más claras, más breves y correctas por defecto. CSS interpreta el atributo «class» como una lista de tokens, que es precisamente lo que le falta a XPath.
Utiliza XPath cuando la clase sea secundaria y el criterio real sea algo que CSS no pueda expresar —sobre todo, el contenido de texto—. //div[contains(@class,'card')][.//span[contains(., 'Sold out')]] necesita XPath para la condición de texto, y la parte de la clase viene de paso.
Mézclalos. Todas las bibliotecas de análisis sintáctico habituales admiten ambas opciones. lxml tiene cssselect junto con xpath; Selenium acepta ambas estrategias de localización; Playwright acepta ambas. Usar CSS para la selección estructural y XPath para las condiciones de texto no es un compromiso, es la combinación que produce el código más breve y legible.
El único caso en el que se utiliza la selección de clases con XPath es cuando la necesitas dentro de una expresión XPath más amplia y no es posible cambiar de lenguaje a mitad de la expresión. Esa es una limitación real y es la razón por la que existe esta técnica, pero su ámbito de aplicación es más reducido que la cantidad de coincidencias de clases con XPath que encontrarás en la práctica.
Preguntas frecuentes
¿Cómo selecciono un elemento por clase en XPath?
Utiliza «//div[contains(concat(' ', normalize-space(@class), ' '), ' card ')]». El relleno garantiza que solo coincidan los tokens de clase completos. La expresión simple «@class='card'» omite elementos con clases adicionales, y «contains(@class,'card')» también coincide con «card-large» y «discard».
¿Por qué contains(@class, 'name') encuentra elementos incorrectos?
Porque se trata de una simple comprobación de subcadenas sin tener en cuenta los límites de las palabras. El atributo «class» es una lista separada por espacios, pero XPath lo ve como una sola cadena, por lo que contains(@class,'card') encuentra cualquier nombre de clase que contenga esos cuatro caracteres en cualquier posición.
¿Cómo selecciono un elemento con dos clases en XPath?
Combina dos condiciones «padded-concat» con and. Funciona y tiene unos 140 caracteres. Con dos o más condiciones de clase, un selector CSS — div.card.featured — resulta mucho más claro, y la mayoría de las bibliotecas de análisis sintáctico te permiten utilizarlo.
¿Importa el orden de las clases en XPath?
No con la construcción «padded-concat», que coincide con un token independientemente de dónde aparezca en el atributo. Sí importa en la comparación exacta, lo cual es una de las varias razones por las que @class='card featured' es una mala elección. El orden de las clases carece de sentido en HTML, por lo que un selector que dependa de él es un error en potencia.
¿Cómo se gestionan los nombres de clase generados aleatoriamente?
Fíjate en el prefijo estable con starts-with() cuando la herramienta genere uno, utiliza un atributo data-testid si la aplicación lo proporciona, o fíjate en algo completamente distinto, como el texto o la estructura. Para nombres hash totalmente opacos sin parte estable, la selección basada en clases no es viable.
¿Qué es mejor para seleccionar por clase, XPath o CSS?
CSS, sin duda. Trata el atributo «class» como una lista de tokens, que es lo que es, por lo que «div.card» es más corto y correcto. XPath necesita una expresión de setenta caracteres para expresar lo mismo. Utiliza XPath cuando también necesites algo que CSS no pueda hacer, como buscar coincidencias de texto.
¿Cómo encuentro un elemento padre por clase en XPath?
//span[@class='price']/ancestor::div[contains(concat(' ',normalize-space(@class),' '),' card ')][1]. El [1] selecciona el antepasado más cercano que coincida, ya que ancestor:: es un eje inverso en el que la posición 1 significa «el más cercano» en lugar de «el más externo».
¿Por qué mi XPath relativo devuelve el mismo valor para todos los elementos?
Porque has utilizado // en lugar de .// dentro del bucle. Un // al principio busca desde la raíz del documento independientemente del contexto, por lo que cada iteración encuentra la primera coincidencia en toda la página. .// busca dentro del elemento actual, que es lo que pretendías.
Conclusión
La selección de clases es donde XPath deja ver su antigüedad. El atributo «class» es una lista de tokens y XPath no dispone de operaciones con tokens, por lo que expresar «tiene esta clase» requiere rellenar la cadena con espacios y buscar un token rellenado: una construcción de setenta caracteres para lo que CSS expresa en nueve.
Aprende esta forma de expresarlo, porque la necesitarás dentro de expresiones XPath más complejas, y envuélvela en una función auxiliar para escribirla una sola vez en lugar de veinte. El paso de la «normalize-space()» no es opcional: el marcado real contiene saltos de línea y tabulaciones en los atributos de clase, y una expresión no normalizada falla de forma imperceptible ante ellos.
Pero la conclusión más útil es aquella que evita esta forma de expresión. Si tus criterios de selección son clases y nada más, escribe un selector CSS. Todas las bibliotecas habituales admiten ambas opciones; mezclarlas en un mismo archivo es habitual, y elegir la herramienta adecuada para cada expresión da como resultado un código más breve que un compañero pueda leer.
Y trata los nombres de clase como el punto de referencia menos estable disponible. Los nombres generados y con hash cambian al implementarlos; incluso los escritos a mano cambian cada vez que alguien rediseña un componente. Un «data-testid», el texto de un encabezado o la relación de un elemento con algo identificable perdurarán más que el nombre de la clase —y el modo de fallo cuando una clase desaparece no es un error, sino una salida silenciosa, bien formada y vacía—.
