Geonode logo
Geonode Team

Geonode Team

Actualizado: 7 de octubre de 2026

Publicado: 2 de septiembre de 2026

XPath contains(): una guía completa con ejemplos

`contains()` Es la función XPath a la que más se recurre y que menos se entiende. Parece una comprobación de subcadena y solo se comporta como tal cuando se le pasa una cadena. Dado un conjunto de nodos —que es lo que producen la mayoría de las expresiones—, utiliza de forma implícita solo el primer nodo e ignora el resto. Esta guía aborda esa trampa, la expresión para la coincidencia de clases que nadie acierta a la primera, y el tratamiento de los espacios en blanco y las mayúsculas y minúsculas, que XPath 1.0 complica más de lo que debería.

Una breve nota sobre por qué escribimos esto. Somos Geonode; vendemos proxies a personas que extraen datos, por lo que constantemente nos llegan preguntas sobre selectores. La advertencia relevante es breve: los errores de los selectores y los problemas con los proxies producen síntomas completamente diferentes, y confundirlos supone una pérdida de tiempo. Una solicitud bloqueada devuelve una página de desafío o un estado de error. Un contains() defectuoso devuelve una página perfectamente válida y un resultado vacío. Si el HTML está ahí y tu expresión no encuentra nada, este artículo es el lugar adecuado y la red funciona correctamente.

La función en sí

La especificación de XPath 1.0 es concisa:

La función contains devuelve «true» si la cadena del primer argumento contiene la cadena del segundo argumento; en caso contrario, devuelve «false».

Ambos argumentos son cadenas. Dos argumentos, un resultado booleano, distingue entre mayúsculas y minúsculas, sin comodines ni expresiones regulares.

//a[contains(@href, 'download')]
//div[contains(@class, 'product')]
//p[contains(text(), 'Price')]

Lo interesante radica exclusivamente en lo que ocurre cuando el argumento que se proporciona no es una cadena, lo cual ocurre la mayor parte de las veces.

La trampa del conjunto de nodos: contains()

solo ve el primer nodo Este es el error que provoca que «mi XPath funcione en algunas páginas y en otras no», y la especificación lo explica con precisión:

Un conjunto de nodos se convierte en una cadena devolviendo el valor de cadena del nodo del conjunto que ocupa el primer lugar en el orden del documento. Si el conjunto de nodos está vacío, se devuelve una cadena vacía.

Así pues, cuando escribes algo que genera un conjunto de nodos y lo pasas a contains() , XPath descarta silenciosamente todo excepto el primer nodo.

<div>
  <p>Introduction</p>
  <p>Price: £42</p>
  <p>Availability</p>
</div>
contains(//p, 'Price')      → false

Falso, porque //p es un conjunto de tres nodos; la conversión a cadena toma el primero —«Introducción»— y este no contiene «Precio». Los otros dos párrafos nunca se tuvieron en cuenta.

La solución consiste en hacer que el predicado se aplique por nodo en lugar de convertir un conjunto:

//p[contains(., 'Price')]   → the second paragraph

Aquí, contains() se evalúa una vez por cada p , siendo . ese nodo concreto. Esta es la diferencia entre preguntar «¿contiene el conjunto esto?» y «¿qué elementos del conjunto contienen esto?», y normalmente lo que se quiere decir es solo lo segundo.

La misma trampa aparece con text() , que también es un conjunto de nodos:

//div[contains(text(), 'Price')]

text() devuelve todos los hijos directos de tipo nodo de texto, y la conversión a cadena toma el primero. Si el elemento tiene texto dividido en varios nodos —lo que ocurre siempre que hay marcado anidado—, solo se comprueba el primer fragmento.

«.

» frente a «text()

»: la otra mitad del mismo problema La especificación define el valor de cadena de un elemento como:

la concatenación de los valores de cadena de todos los nodos de texto descendientes del nodo elemento, en el orden del documento

Esa es la diferencia fundamental entre las dos formas.

<p>Total: <strong>£42.00</strong> including VAT</p>
contains(., '£42.00')          → true   (all descendant text, concatenated)
contains(text(), '£42.00')     → false  (first direct text node only: "Total: ")

.

se adentra en los elementos anidados. text()

solo tiene en cuenta los hijos directos, y únicamente el primero de ellos.

**Utiliza .

por defecto.** Coincide con lo que un lector consideraría «el texto de este elemento» y se mantiene incluso ante cambios en el marcado, como cuando alguien envuelve un valor en un <span>

.

**Utiliza «text()

» deliberadamente** cuando quieras excluir específicamente contenido anidado; por ejemplo, para buscar una etiqueta sin incluir el texto que haya dentro de una insignia o una descripción emergente hija.

Para «cualquiera de los nodos de texto contiene esto», la forma correcta aplica el predicado a los propios nodos de texto:

//div[text()[contains(., 'Price')]]

. Es prolijo, pero correcto.

Espacios en blanco: «normalize-space()» no es opcional

El código HTML real se formatea de forma legible, y los espacios en blanco se incluyen en el valor de la cadena.

<td>
    In stock
</td>

El valor de la cadena de esa celda es «"\n In stock\n"», por lo que una comparación exacta con «'In stock'» falla.

La especificación define la solución:

La función normalize-space devuelve la cadena del argumento con los espacios en blanco normalizados, eliminando los espacios en blanco iniciales y finales y sustituyendo las secuencias de caracteres de espacio en blanco por un único espacio.

//td[normalize-space() = 'In stock']
//td[contains(normalize-space(), 'In stock')]

Ten en cuenta que normalize-space() sin ningún argumento actúa sobre el nodo de contexto, que es lo que te interesa dentro de un predicado.

En el caso concreto de contains(), los espacios en blanco importan menos en los extremos y mucho más en el centro. Una búsqueda de 'In stock' falla frente a "In stock" a menos que se normalice primero. Si solo vas a incorporar un hábito en tus selectores, que sea envolver las comparaciones de texto en normalize-space().

Cómo hacer coincidir clases correctamente

El uso incorrecto más habitual de ``contains()`

, y el que tiene más probabilidades de dar error de forma silenciosa.

//div[contains(@class, 'btn')]

`

Esto coincide con ``class="btn"`

. También coincide con ``class="btn-primary"

, ``class="unbtn"

y ``class="sidebar-btn-group"

. Dado que ``@class

es una cadena separada por un solo espacio y ``contains()

` es una simple comprobación de subcadena, no tiene en cuenta los límites de las palabras.

La forma correcta consiste en añadir espacios tanto al atributo como al valor de destino, de modo que solo coincidan tokens completos:

//div[contains(concat(' ', normalize-space(@class), ' '), ' btn ')]

Léelo así: toma el atributo «class», normaliza sus espacios en blanco, envuélvelo en espacios para que cada token esté delimitado por espacios a ambos lados y busca el valor de destino rodeado de espacios. «class="btn-primary"

» se convierte en «" btn-primary "

», que no contiene «" btn "

». «class="icon btn large"

» se convierte en «" icon btn large "

», que sí lo contiene.

Es feo. Pero también es correcto, y es a lo que acaba llegando cualquier código maduro de scraping. Envuélvelo en una función auxiliar:

def has_class(name):
    return (f"contains(concat(' ', normalize-space(@class), ' '), ' {name} ')")

. Cuando necesites dos clases:

//div[contains(concat(' ', normalize-space(@class), ' '), ' btn ')
      and contains(concat(' ', normalize-space(@class), ' '), ' primary ')]

En ese momento, un selector CSS — div.btn.primary

— resulta mucho más legible y hace exactamente lo que hay que hacer. Si solo buscas coincidencias en clases y nada más, utiliza CSS. XPath cobra sentido cuando necesitas buscar coincidencias de texto o navegar hacia atrás, no para las cosas que el CSS ya hace bien. Comparamos ambos en XPath preceding-sibling.

Distinción entre mayúsculas y minúsculas y la solución alternativa «translate()»

La expresión «contains()» distingue entre mayúsculas y minúsculas, y XPath 1.0 no dispone de una función «lower-case()». Los navegadores y Selenium implementan XPath 1.0, por lo que esta limitación se aplica precisamente donde más importa.

La solución alternativa utiliza translate(), definida como la función que devuelve «la cadena del primer argumento en la que las apariciones de los caracteres de la cadena del segundo argumento se sustituyen por el carácter de la posición correspondiente en la cadena del tercer argumento»:

//p[contains(translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
                          'abcdefghijklmnopqrstuvwxyz'), 'price')]

Transliteración carácter por carácter, solo ASCII. No convertirá los caracteres acentuados a minúsculas a menos que amplíes ambas cadenas para incluirlos, lo que se vuelve complicado rápidamente.

Hay dos opciones mejores disponibles:

Buscar una subcadena que no distinga entre mayúsculas y minúsculas. Si la página dice «Price» o «PRICE», pero nunca «price», buscar «'rice'» es poco elegante, pero funciona. A menudo es la respuesta más práctica.

Utilizar una biblioteca con un XPath más completo. Los analizadores del lado del servidor, como lxml, admiten extensiones EXSLT, entre ellas re:test() para expresiones regulares, que gestionan las mayúsculas y minúsculas y muchas otras cosas. Los navegadores no lo hacen, por lo que un fragmento de código que hayas encontrado para lxml puede fallar en Selenium precisamente por este motivo.

Si te ves escribiendo un «translate()» largo, es una señal de que has superado las capacidades de XPath 1.0 para esta tarea.

Las funciones de cadenas relacionadas «

contains()

» forman parte de una pequeña familia, y las demás suelen ser más precisas.

** «starts-with()

»** — «devuelve true si la cadena del primer argumento comienza por la cadena del segundo argumento». Es más específica que «contains()

» y, por lo tanto, menos propensa a dar coincidencias excesivas:

//a[starts-with(@href, 'https://')]

En XPath 1.0 no existe la función «ends-with()

». La solución alternativa utiliza substring()

y string-length()

, y resulta tan engorrosa que suele ser preferible un enfoque diferente.

**substring-before()

y substring-after()

** — la primera «devuelve la subcadena de la cadena del primer argumento que precede a la primera aparición de la cadena del segundo argumento... o la cadena vacía si la cadena del primer argumento no contiene la del segundo». Útil para dividir un valor dentro de la expresión:

substring-after(//span[@class='price'], '£')

**normalize-space()

** — ya se ha tratado anteriormente, y es la que más deberías utilizar.

**translate()

** — conversión de mayúsculas a minúsculas, y también eliminación de caracteres asignándoles el valor «nada»:

translate(., ',', '')

**string-length()

** — filtrado de valores vacíos o truncados:

//td[string-length(normalize-space()) > 0]

Su combinación es donde reside el valor:

//tr[contains(normalize-space(td[1]), 'Weight')]/td[2]

La segunda celda de cualquier fila cuya primera celda mencione «Peso», sin distinción entre mayúsculas y minúsculas.

Patrones que se repiten constantemente

Las expresiones que aparecen a continuación abarcan la mayor parte del trabajo real de extracción y conviene tenerlas a mano.

Buscar el valor que aparece junto a una etiqueta. El requisito más habitual a la hora de extraer datos de páginas estructuradas:

//dt[contains(normalize-space(), 'Price')]/following-sibling::dd[1]
//th[contains(normalize-space(), 'Weight')]/following-sibling::td[1]

Fíjate en el [1]

: sin él, following-sibling::td

devuelve todas las celdas posteriores de la fila, y tu código toma silenciosamente la primera mientras tú das por hecho que era la única.

Buscar un enlace por su texto visible en lugar de por su href:

//a[contains(normalize-space(), 'Download')]

Es más robusto que buscar la coincidencia de la URL cuando esta es un identificador con hash, y más frágil cuando el sitio está traducido. Elige según cuál cambie con más frecuencia.

Busca un contenedor por algo que haya en su interior:

//div[contains(concat(' ', normalize-space(@class), ' '), ' card ')][.//span[contains(., 'Sold out')]]

Dos predicados seguidos: una tarjeta que contenga un span en el que se mencione «Agotado». Esto se combina bien y se lee mejor que intentar expresarlo en una sola condición.

Excluir en lugar de incluir. A menudo es la formulación más clara:

//tr[not(contains(@class, 'header'))]
//li[not(contains(normalize-space(), 'Advertisement'))]

**Buscar un botón, ya sea button

o a

:**

//*[self::button or self::a][contains(normalize-space(), 'Continue')]

Buscar la fila que contenga un valor y tomar una columna diferente:

//tr[td[contains(normalize-space(), 'SKU-1234')]]/td[3]

Léelo de fuera hacia dentro: filas que tengan una celda que mencione el SKU y, a continuación, la tercera celda de esa fila. Este es el patrón de búsqueda en tabla, y resiste mucho mejor la reordenación de columnas que un índice absoluto en toda la tabla.

Protégete contra las coincidencias vacías. Un predicado que filtre los espacios en blanco no cuesta nada y evita una clase de confusión posterior:

//td[string-length(normalize-space()) > 0][contains(., 'Ltd')]

Cuándo «contains()» no es la herramienta adecuada

Cuando te refieres a la igualdad. «contains(., 'Price')» también coincide con «Historic Price» y «Price excluding VAT». Si quieres exactamente esa etiqueta, utiliza «normalize-space() = 'Price'». Las coincidencias excesivas pasan desapercibidas: tu código toma el primer resultado y nunca sabe que había tres.

Cuando estás comparando clases y nada más. El CSS lo hace correctamente y de forma legible. Véase más arriba.

Cuando necesitas una expresión regular. XPath 1.0 no tiene ninguna. Extrae los datos con contains() si es necesario y, a continuación, aplica una expresión regular en tu lenguaje de programación, donde también podrás ver qué ha coincidido.

Cuando hay un identificador utilizable. Un id, un atributo «data» o JSON-LD incrustado es más estable que cualquier coincidencia de texto. El texto es contenido, y el contenido cambia: un rediseño, una traducción o una revisión editorial rompen un selector basado en coincidencia de texto y nada te avisa.

Cuando la cadena procede de una entrada del usuario. Interpolar texto no fiable en una expresión XPath se conoce como «inyección de XPath». Utiliza el enlace de variables de tu biblioteca cuando exista y escapa correctamente cuando no lo haya —en particular las comillas—, ya que XPath 1.0 no tiene secuencia de escape para una comilla dentro de un literal de cadena y tienes que utilizar concat() para crear una.

Preguntas relacionadas

¿Qué hace la función contains() en XPath?

Devuelve «true» cuando la primera cadena argumentada contiene la segunda como subcadena. Ambos argumentos son cadenas, la comparación distingue entre mayúsculas y minúsculas, y no admite comodines ni expresiones regulares. Su función habitual en la extracción es encontrar un elemento por parte de su texto o del valor de un atributo.

¿Por qué mi función contains() de XPath no encuentra nada?

Lo más habitual es que se le haya pasado un conjunto de nodos. XPath convierte un conjunto de nodos en una cadena tomando el primer nodo en el orden del documento e ignorando el resto, por lo que contains(//p, 'x') solo examina el primer párrafo. En su lugar, aplica el predicado por nodo: //p[contains(., 'x')].

¿Cuál es la diferencia entre contains(.) y contains(text())? `

.`` utiliza el valor de cadena del elemento, que la especificación define como la concatenación de todos los nodos de texto descendientes, por lo que llega hasta el marcado anidado. ``text()`` devuelve los nodos de texto hijos directos, y la conversión a cadena solo toma el primero. Utiliza .` a menos que quieras excluir específicamente el contenido anidado.

¿Cómo puedo buscar una clase con XPath?

Utiliza contains(concat(' ', normalize-space(@class), ' '), ' name '), que rellena el atributo para que solo coincidan los tokens completos. Un simple contains(@class, 'btn') también coincide con btn-primary y unbtn. Si solo buscas clases, un selector CSS es más claro y, por defecto, más correcto.

¿La función contains() de XPath distingue entre mayúsculas y minúsculas?

Sí, y XPath 1.0 no dispone de la función «lower-case()». La solución habitual es utilizar «translate()» con letras mayúsculas y minúsculas explícitas, aunque solo admite caracteres ASCII. Las bibliotecas del lado del servidor, como lxml, admiten expresiones regulares EXSLT; los navegadores y Selenium, en cambio, no.

¿Cómo se utiliza «contains()» con varias condiciones?

Combina los predicados con «and» y «or»: //div[contains(@class, 'card') and contains(., 'In stock')]. Cada «contains()» es una prueba booleana independiente que se evalúa con respecto al mismo nodo de contexto.

¿Dispone XPath de una función «empieza por» o «termina en»?

Existe starts-with() y es preferible a contains() cuando sea adecuado, ya que produce menos coincidencias excesivas. No existe ends-with() en XPath 1.0; la solución alternativa consiste en utilizar substring() junto con string-length(), pero resulta tan engorrosa que suele ser mejor optar por un enfoque diferente.

¿Por qué contains() encuentra más elementos de los esperados?

Porque es una comprobación de subcadenas sin tener en cuenta los límites de las palabras. contains(., 'Price') encuentra también «Historic Price» y «Price excluding VAT». Utiliza normalize-space() = 'Price' para la igualdad, o la expresión «padded-concat» para los tokens de clase.

Conclusión: «

contains()» es sencillo en teoría, pero está lleno de trampas a la hora de utilizarlo, y casi todas ellas tienen su origen en un mismo lugar: XPath convierte un conjunto de nodos en una cadena tomando el primer nodo y descartando el resto. Esa única regla explica por qué contains(//p, 'x') ofrece una respuesta errónea con total seguridad, por qué contains(text(), 'x') omite el texto repartido entre varios nodos y por qué la misma expresión funciona en una página y falla en la siguiente.

Los hábitos que permiten evitarlo son pocos. Aplica contains() dentro de un predicado para que se evalúe por cada nodo. Utiliza . en lugar de text() a menos que tengas una razón para no hacerlo. Envuelve las comparaciones de texto en normalize-space(), ya que el HTML real se formatea de forma legible. Y para la coincidencia de clases, utiliza la construcción «padded-concat» o —mejor aún— utiliza un selector CSS, que fue diseñado precisamente para esa tarea.

Reserva XPath para lo que hace de forma exclusiva: buscar coincidencias en texto y navegar hacia atrás. Esas son capacidades reales sin equivalente en CSS, y merecen la pena el uso de esa sintaxis. Usarlo para seleccionar div.card es pagar el precio sin obtener el beneficio.