Una nota sobre quién ha escrito esto y por qué no tiene mucha importancia en este contexto: somos Geonode y vendemos proxies a personas que se dedican a la extracción de datos. La elección del selector no influye en nada de lo que vendemos, y el único punto en común que merece la pena mencionar es de carácter diagnóstico: un selector que no coincide con nada y una solicitud que ha sido bloqueada parecen idénticos desde fuera, y son problemas que no guardan relación alguna. Si la página se ha cargado y tu selector no ha encontrado nada, esta referencia es el lugar adecuado y no hay que cambiar nada en cuanto a cómo se ha recuperado la página.
Selectores básicos
| Selector | Coincide con |
|---|---|
* | Cualquier elemento |
div | Cualquier elemento div |
.card | Elementos con la clase card |
#main | El elemento con el id main |
.card.featured | Elementos con ambas clases |
div.card | Elementos div con la clase card |
div, p | Elementos div y elementos p |
Vale la pena detenerse en dos de estos casos.
.card.featured sin espacio significa que un mismo elemento tiene ambas clases. .card .featured con un espacio significa un elemento con la clase featured dentro de un elemento con la clase card. Un solo carácter cambia por completo el significado, y es el error tipográfico más común en todo este ámbito.
div, p es una lista de selectores, no una relación. Coincide con todo lo que haya en cualquiera de los dos grupos, y cada parte se evalúa de forma independiente; por lo tanto, div, p.note coincide con todos los divs y párrafos con la clase note, no con los divs con la clase note.
Combinadores
Expresan relaciones entre elementos.
| Selector | Coincide con |
|---|---|
div p | p en cualquier lugar dentro de un div (descendiente) |
div > p | p que sea un hijo directo de un div |
h2 + p | El p inmediatamente después de un h2 (hermano adyacente) |
h2 ~ p | Cada p que siga a un h2 con el mismo padre (hermano general) |
El combinador de descendientes es un espacio, lo que hace que sea fácil introducirlo accidentalmente y que pase desapercibido al leer.
> es más preciso y, por lo general, más robusto. div > p no coincidirá con un párrafo que alguien envuelva en un <section> el mes que viene —lo que puede parecer una desventaja, pero a menudo es todo lo contrario, ya que un selector que, de forma silenciosa, empieza a coincidir con más elementos de los previstos es peor que uno que deja de coincidir y te lo indica—.
+ y ~ son los combinadores de hermanos y solo miran hacia adelante. No existe un combinador de «hermanos anteriores» en CSS; :has() proporciona el efecto de forma indirecta, como se explica a continuación.
Selectores de atributos
A menudo son la opción más estable disponible, ya que los atributos suelen elegirse de forma deliberada.
| Selector | Coincide con |
|---|---|
[href] | Elementos con el atributo «href» |
[href="/about"] | Valor exacto |
[href^="/docs"] | El valor empieza por |
[href$=".pdf"] | El valor termina en |
[href*="download"] | El valor contiene |
[class~="card"] | El valor es una lista separada por espacios que contiene la palabra card |
[lang|="en"] | El valor es en o empieza por en- |
[data-state="open" i] | Coincidencia sin distinción entre mayúsculas y minúsculas |
Hay dos que se utilizan poco.
[class~="card"] es exactamente equivalente a .card: el operador ~= realiza una coincidencia de palabras separadas por espacios en blanco, que es lo que necesita el atributo «class». Saber que existe resulta útil principalmente para comprender qué hace realmente .card y para crear selectores mediante programación cuando se dispone de un nombre de atributo en una variable.
El indicador i hace que la coincidencia no distinga entre mayúsculas y minúsculas, lo cual resulta realmente útil para valores de atributos cuya ortografía varía en mayúsculas y minúsculas a lo largo de un sitio web.
Para la extracción en concreto, los atributos data-* merecen una clara preferencia frente a las clases. Un [data-testid="product-card"] fue elegido por un desarrollador para que fuera un punto de referencia estable; un nombre de clase puede ser regenerado por una herramienta de compilación en cada implementación.
Pseudoclases: Estructurales
| Selector | Coincide con |
|---|---|
:first-child | Un elemento que es el primer hijo de su padre |
:last-child | El último hijo |
:only-child | Un elemento sin hermanos |
:nth-child(3) | El tercer hijo |
:nth-child(2n) | Hijos con número par |
:nth-child(2n+1) | Hijos con número impar |
:nth-child(-n+3) | Los tres primeros |
:nth-last-child(2) | El penúltimo |
:first-of-type | El primer elemento de su tipo entre los hermanos |
:nth-of-type(2) | El segundo de su tipo |
:empty | Un elemento sin hijos, incluido el texto |
:root | La raíz del documento, normalmente html |
Las variantes de «-of-type» son más importantes de lo que la gente cree. «p:first-child» solo coincide con un párrafo si este es el primer elemento secundario de su elemento principal; por lo tanto, si aparece primero un encabezado, no coincide con nada. «p:first-of-type» coincide con el primer párrafo independientemente de lo que lo preceda. Para la extracción, la segunda opción es casi siempre la que se pretende.
:nth-child(-n+3) para «los tres primeros» es la expresión que vale la pena memorizar de la sintaxis An+B, junto con :nth-child(n+4) para «a partir del cuarto». Al combinarlas se obtiene un rango: :nth-child(n+2):nth-child(-n+5) selecciona los elementos hijos del segundo al quinto.
Pseudoclases: Lógicas
El grupo que más ha cambiado en los últimos años.
| Selector | Coincidencias |
|---|---|
:not(.hidden) | Elementos sin clase hidden |
:is(h1, h2, h3) | Cualquiera de los selectores enumerados |
:where(h1, h2, h3) | Igual que :is(), pero con especificidad cero |
:has(> img) | Elementos que contienen un descendiente o un elemento hermano que coincida |
:is() y :where() acortan las listas repetitivas. :is(article, section) > h2 sustituye a article > h2, section > h2, y el ahorro se acumula rápidamente con listas más largas. La única diferencia entre ellos es la especificidad: :is() adopta la especificidad de su argumento más específico, mientras que :where() siempre aporta cero.
:not() admite una lista de selectores en los navegadores modernos, por lo que :not(.a, .b) funciona y significa «ninguno». El comportamiento anterior con un solo argumento requería encadenar :not(.a):not(.b).
:has() es el más relevante. La documentación de MDN lo describe como «un elemento si cualquiera de los selectores relativos que se pasan como argumento coincide con al menos un elemento cuando se ancla a este elemento», lo que proporciona «una forma de seleccionar un elemento padre o un elemento hermano anterior con respecto a un elemento de referencia». Su estado es «Baseline widely available» (disponible de forma generalizada), y es compatible con todos los navegadores desde diciembre de 2023.
div.card:has(span.sold-out) /* a card containing a sold-out marker */
h1:has(+ p) /* an h1 immediately followed by a p */
li:has(~ li.active) /* an li with a later active sibling */
label:has(input:checked) /* a label wrapping a checked input */
Tres limitaciones documentadas: «no se puede anidar» dentro de otro :has(), los pseudoelementos no son válidos en su interior ni como anclajes para él, y su especificidad sigue la misma regla que :is() y :not(): el argumento más específico.
Pseudoclases: State y Form
| Selector | Coincide con |
|---|---|
:hover, :focus, :active | Estados de interacción |
:focus-visible | Foco que debería mostrar un anillo visible |
:focus-within | Un elemento que contiene un descendiente con el foco |
:checked | Casilla de selección marcada, botón de opción o opción seleccionada |
:disabled, :enabled | Estado de los controles de formulario |
:required, :optional | Atributos de validación de formulario |
:valid, :invalid | Estado de validación de restricciones |
:placeholder-shown | Un campo de entrada que muestra su marcador de posición |
:target | El elemento que coincide con el fragmento de la URL |
:visited, :link | Estados de los enlaces |
La mayoría de estos son cuestiones de estilo más que de extracción, con dos excepciones. :checked resulta realmente útil para leer el estado de un formulario en la automatización de pruebas, y :disabled indica si un control es interactivo —lo que a menudo explica por qué un clic no ha surtido efecto—.
Ten en cuenta que :hover y :focus no pueden consultarse de forma útil con querySelectorAll en un contexto de scraping, ya que ninguno de esos estados existe en un documento que simplemente has analizado.
La especificidad, en pocas palabras
Es relevante cuando los selectores compiten por aplicar estilos, y no lo es a la hora de realizar consultas; conviene saberlo porque es habitual confundir ambos contextos.
La especificidad se calcula a partir de tres valores:
| Componente | Aportado por |
|---|---|
| ID | #main |
| Clases, atributos, pseudoclases | .card, [href], :hover |
| Elementos, pseudoelementos | div, ::before |
Comparando de izquierda a derecha: ningún número de clases supera jamás a un único identificador. Los estilos en línea superan a todos ellos, y !important supera a estos, por lo que ambos son un último recurso más que una herramienta.
Dos notas sobre los selectores modernos. :where() no aporta ninguna especificidad, lo que lo convierte en la opción adecuada para los valores predeterminados de las bibliotecas, que los usuarios deberían poder anular sin dificultad. Y :is(), :not() y :has() adoptan la especificidad de su argumento más específico, por lo que :is(#main, div) es tan específico como #main.
En el caso de querySelectorAll y scraping, la especificidad no se aplica en absoluto. El selector o bien coincide con un elemento o bien no; no hay nada que compita con él.
Patrones de extracción
Los selectores que realmente hacen el trabajo en un rastreador.
Fichas de productos en una cuadrícula:
div.product-grid > article.product-card
Enlaces a archivos PDF:
a[href$=".pdf"]
Enlaces externos:
a[href^="http"]:not([href*="example.com"])
Un gancho de prueba estable en lugar de una clase:
[data-testid="price"]
Un contenedor que contiene algo:
tr:has(td.error)
article:has(img)
form:has(input:invalid)
El primer párrafo de un artículo, independientemente de lo que le preceda:
article p:first-of-type
Omitir una fila de encabezado:
tbody tr:not(:first-child)
Seleccionar las filas de la dos a la once:
tbody tr:nth-child(n+2):nth-child(-n+11)
Elementos con un token de clase específico, creados mediante programación:
[class~="card"]
Celdas vacías que se desean excluir:
td:not(:empty)
Cómo escribir selectores que no dejen de funcionar
Una referencia te indica qué elementos están disponibles. La durabilidad radica en la elección entre las opciones, y la diferencia entre un selector que sobrevive a un rediseño y otro que deja de funcionar a la semana siguiente radica exclusivamente en aquello de lo que depende.
Clasifica tus anclajes según el grado de deliberación con el que se eligieron. Un «data-testid» existe porque un desarrollador lo puso ahí para la automatización, por lo que es lo más estable de la página. Le sigue el «id»: normalmente intencional, aunque en ocasiones generado. Un elemento semántico o un rol ARIA es estable porque tiene significado. Un nombre de clase escrito a mano es moderadamente estable. Un nombre de clase generado, como css-1x9dj2k, cambia en cada compilación y no tiene ningún valor. Los selectores posicionales son los menos estables de todos, ya que codifican la forma exacta de la página en un momento dado.
Sé tan específico como requiera el significado y nada más. body > div > div > div.content > p es preciso y frágil: cualquier contenedor añadido en cualquier punto de esa cadena lo rompe. .content p expresa el requisito real y sobrevive a la reestructuración. El instinto de añadir especificidad por seguridad produce lo contrario a la seguridad.
Prefiere selectores que fallen de forma evidente. Un selector que coincida con cero elementos plantea un problema evidente. Un selector que coincida con demasiados elementos devuelve silenciosamente el primero, lo cual es plausible pero posiblemente erróneo. Si puedes elegir, opta por la versión que fallará abiertamente en lugar de la que se desviará: div > p en lugar de div p cuando la relación sea realmente directa.
Mantén los selectores en un solo lugar. Cuarenta cadenas de selectores dispersas por el código son cuarenta puntos débiles independientes. Si se recopilan en un único módulo con nombres descriptivos, se convierten en un mapa de aquello de lo que dependes, y actualizarlas tras un cambio en el sitio web supone una hora de trabajo en lugar de un día de trabajo de arqueología.
Verifica la estructura de lo que extraes. Este es el hábito que detecta las desviaciones silenciosas. Si un precio debe ajustarse a un patrón de moneda, compruébalo. Si una lista nunca ha tenido menos de veinte elementos, considera que tener menos de veinte es un error. Sin esto, un selector que empieza a coincidir con el elemento equivocado produce datos erróneos pero bien formados de forma indefinida, y nada genera una excepción en ningún momento.
Guarda el HTML que has analizado. Cuando un selector deja de funcionar, comparar el marcado antiguo con el nuevo te muestra exactamente qué ha cambiado en cuestión de segundos. Volver a cargar la página para investigar es más lento, consume ancho de banda y puede que te muestre una página diferente a la que realmente falló.
Lo que el CSS aún no puede hacer
Una breve lista, y cada punto es una razón de peso para recurrir a XPath.
Búsqueda por contenido de texto. No existe un estándar :contains(). Se propuso y se descartó, y ningún navegador lo implementa. Esta es la mayor carencia y la razón por la que el patrón de «buscar un valor por su etiqueta» es terreno de XPath: //dt[normalize-space()='Price']/following-sibling::dd[1] no tiene equivalente en CSS.
Navegar hasta un antepasado arbitrario. :has() permite la selección condicional de padres —«el div que contiene este span»—, pero no existe un eje de antepasados general. Subir varios niveles hasta un contenedor específico requiere XPath.
Funciones de cadena. No hay recorte, ni subcadenas, ni conversión de mayúsculas a minúsculas, ni concatenación. Sea lo que sea lo que coincida, hay que procesarlo después en el lenguaje de host.
Consultar XML con espacios de nombres. CSS no tiene soporte para espacios de nombres digno de mención. Para mapas de sitio, RSS o SOAP, utiliza XPath.
Seleccionar directamente los hermanos anteriores. «:has()» proporciona el efecto —«h2:has(+ p)» selecciona el encabezado—, pero no existe un combinador «-» que refleje «+».
Esa es la lista completa a efectos prácticos. Todo lo demás para lo que la gente recurre a XPath, ahora lo hace CSS, y normalmente de forma más legible.
Diferencias entre entornos que conviene tener en cuenta
La sorpresa más habitual con los selectores es que una característica exista en un lugar distinto al que se está ejecutando.
Los navegadores admiten todo lo que se enumera aquí, incluyendo :has(), :is(), :where() y :not() con múltiples argumentos.
Los analizadores del lado del servidor varían considerablemente. El analizador «cssselect» de Python, utilizado por lxml y Scrapy, traduce CSS a XPath, lo que funciona bien con los selectores clásicos y no tan bien con los más recientes. En concreto, la compatibilidad con «:has()» no es universal, y un selector que funcione en la consola de un navegador puede generar un error o no devolver ningún resultado de forma silenciosa en un analizador.
BeautifulSoup utiliza soupsieve para su método select(), que ofrece una buena cobertura, aunque no idéntica, y no admite XPath en absoluto.
Playwright y Selenium utilizan el motor del navegador, por lo que la compatibilidad coincide con la del navegador; además, Playwright ofrece localizadores de texto y de rol que cubren gran parte de lo que el CSS no puede expresar.
El consejo práctico: prueba los selectores en el entorno en el que se ejecutarán. La consola del navegador es cómoda, pero prueba una implementación diferente frente a un DOM post-JavaScript, lo que supone dos formas en las que puede llevarte a conclusiones erróneas.
Preguntas frecuentes
¿Cuál es la diferencia entre .card.featured y .card .featured?
El primero, sin espacio, selecciona un elemento que tenga ambas clases. El segundo, con un espacio, selecciona un elemento con la clase featured dentro de un elemento con la clase card. Un solo espacio cambia completamente el significado y es el error más común en los selectores CSS.
¿Cómo selecciono un elemento padre en CSS?
Con :has() — div:has(> span.price) selecciona el div en lugar del span. Está ampliamente disponible en todos los navegadores desde diciembre de 2023. No existe un selector de antepasados general, por lo que para acceder a un contenedor específico varios niveles por encima sigue siendo necesario utilizar XPath.
¿Pueden los selectores CSS coincidir con texto?
No. No existe una pseudoclase estándar «:contains()»; se propuso, pero nunca se adoptó. Para realizar coincidencias por contenido de texto se requiere XPath o un localizador específico de una biblioteca, como «getByText()» de Playwright.
¿Cuál es la diferencia entre :nth-child y :nth-of-type?
:nth-child cuenta todos los hijos del elemento padre; :nth-of-type cuenta solo los elementos del mismo tipo. Así, p:first-child no encuentra nada si aparece primero un encabezado, mientras que p:first-of-type encuentra el primer párrafo independientemente de ello.
¿Qué hace :is() y en qué se diferencia de :where()?
Ambos aceptan una lista de selectores y coinciden con cualquiera de ellos, acortando así los selectores repetitivos. La única diferencia es la especificidad: :is() adopta la especificidad de su argumento más específico, mientras que :where() siempre aporta cero —lo que hace que :where() sea ideal para valores por defecto que deben ser fáciles de anular.
¿Cómo funciona la especificidad en CSS?
Los identificadores (id) tienen mayor peso que las clases, los atributos y las pseudoclases, que a su vez tienen mayor peso que los selectores de elementos y pseudoelementos, comparando componente por componente. Los estilos en línea superan a los selectores, y !important los supera a todos. Nada de esto se aplica cuando se utilizan selectores para realizar consultas en lugar de para aplicar estilos.
¿Son los selectores CSS mejores que XPath para el scraping?
Para la selección por clase, identificador, atributo y estructura, sí: son más cortos y claros. Para buscar contenido de texto o remontarse hasta un antepasado arbitrario, se requiere XPath. La mayoría de las bibliotecas de scraping admiten ambos, y es habitual utilizar cada uno según convenga.
¿Por qué mi selector funciona en el navegador pero no en mi programa de scraping?
Hay dos causas habituales. El DOM del navegador se genera tras la ejecución de JavaScript, mientras que tu analizador ve el HTML sin procesar, por lo que es posible que el elemento no exista en lo que has obtenido. Además, las implementaciones de CSS del lado del servidor no cubren todos los selectores modernos; en concreto, :has() no es compatible de forma uniforme fuera de los navegadores.
Conclusión
El núcleo funcional de los selectores CSS es reducido: «type», «class» e «id» para la identidad; los cuatro combinadores para las relaciones; los selectores de atributos para cualquier elemento elegido deliberadamente; y un puñado de pseudoclases para la posición y el estado.
Hay tres hábitos que mejoran los selectores reales más que conocer sintaxis adicional. Prefiere > en lugar de un espacio en blanco cuando la relación sea realmente directa, ya que un selector que deja de coincidir es más útil que uno que empieza a coincidir en demasiados casos. Prefiere los atributos data-* en lugar de los nombres de clase, ya que las clases son regeneradas por las herramientas de compilación y los atributos los eligen las personas. Y prefiere :nth-of-type en lugar de :nth-child cuando te refieras al «segundo párrafo» en lugar de al «segundo elemento hijo».
:has() Es una novedad que merece la pena adoptar deliberadamente. La selección de elementos padres y las condiciones de elementos hermanos en CSS puro eliminan gran parte de lo que antes obligaba a los usuarios a recurrir a XPath, y lleva ya suficiente tiempo ampliamente disponible como para poder confiar en ello en un navegador. Fuera de los navegadores, comprueba primero tu analizador sintáctico: ahí es donde se esconden las sorpresas restantes.
