Probador de XPath

Evalúa XPath y ve cada coincidencia, con las trampas explicadas.

Entrada
En esperaPega un documento para comprobarlo. La validación se ejecuta mientras escribes.

Todo se ejecuta en esta pestaña. Nada de lo que pegues se sube, se registra ni se envía a ningún sitio. Abre el panel de red y compruébalo.

Pega un documento, escribe una expresión, y cada nodo coincidente se lista con su tipo, la ruta que llega hasta él (/catalog/book[2]/title) y su valor, con el número de coincidencias y el tiempo de evaluación encima de la lista. La evaluación corre sobre el propio motor del navegador mediante document.evaluate, así que esta página no envía ninguna biblioteca XPath y nada de lo que pegues sale de la pestaña.

Recurres a esto cuando una expresión está a punto de ir a un sitio incómodo de depurar: una regla Schematron, un patrón match de XSLT, un splitter de Camel, un selector de scraping. Descubrir aquí que //book no selecciona nada sale más barato que descubrirlo por un resultado vacío y silencioso en producción.

Lo distinto es el manejo de espacios de nombres. La mayoría de los testers gratuitos le pasan a document.evaluate un resolver nulo, así que cualquier expresión con prefijo lanza un error y cualquier documento con espacio de nombres por defecto devuelve cero coincidencias sin explicación. Este recoge todas las declaraciones xmlns del documento, las vincula todas, y nombra la trampa del espacio de nombres por defecto antes de devolverte un resultado vacío.

La expresión que no coincide con nada

Este es el fallo más común de XPath, y su síntoma es indistinguible de que el elemento realmente no esté. Toma un documento cuya raíz lleva xmlns="urn:books". El elemento de dentro no se llama book; su nombre expandido es {urn:books}book. XPath 1.0 no tiene concepto de espacio de nombres por defecto, así que un nombre sin prefijo significa "sin espacio de nombres", y //book pide {}book. No existe tal nodo. Cero coincidencias, ningún error.

MDN lo dice sin rodeos: no hay forma en XPath de recoger el espacio de nombres por defecto tal como se aplica a una referencia de elemento normal. Vincula tú mismo un prefijo a la URI. El prefijo es local a la expresión, así que si el documento dice xmlns:b="urn:books" puedes escribir igualmente //x:book, siempre que vincules x.

Cuando esta página encuentra un espacio de nombres por defecto le vincula el prefijo ns, así que //ns:book funciona sin preparativos, e imprime la trampa encima del resultado con tu URI real dentro. La casilla de espacios de nombres acepta tus propias vinculaciones como pares prefijo=uri, y esas tienen prioridad sobre lo recogido del documento.

  • Vincula un prefijo: //ns:book/ns:title. Lo más corto, y lo que quieres en código.
  • Ignora los espacios de nombres: //*[local-name()="book"]. Coincide con un book de cualquier espacio de nombres, y conviene conocerlo cuando no controlas el resolver.
  • Sé exacto sin prefijo: //*[namespace-uri()="urn:books" and local-name()="book"].
  • Los atributos son distintos. Un espacio de nombres por defecto nunca se aplica a los nombres de atributo, así que en <book xmlns="urn:books" id="7"/> el elemento es {urn:books}book pero el atributo es simplemente {}id. Selecciónalo como @id, no como @ns:id.

Barras, predicados y posiciones

/ es un paso de hijo. // es abreviatura de /descendant-or-self::node()/, y por eso /catalog/book encuentra solo los elementos book directamente bajo la raíz mientras que //book los encuentra a cualquier profundidad. El segundo es más tolerante y bastante más lento en un documento grande, porque visita todos los nodos en vez de los hijos de uno.

Los índices de predicado empiezan en 1, no en 0, así que una expresión que acabe en [0] no devuelve nada en silencio. La trampa más sutil es que un predicado se une a su paso y no a la expresión entera: //book[1] significa "todo book que sea el primer hijo book de su propio padre", así que un documento con tres catálogos devuelve tres nodos. Para la primera coincidencia global necesitas paréntesis: (//book)[1].

position() y last() son funciones del contexto, la lista de nodos que produjo el paso actual: book[last()] es el último book bajo cada padre. Un número suelto es abreviatura de [position() = 2], y por eso //book[@lang="en"][1] y //book[1][@lang="en"] son conjuntos distintos. El primero filtra y luego toma uno; el segundo toma uno y luego filtra.

  • child:: es el eje por defecto, así que book y child::book son la misma expresión.
  • descendant:: busca hacia abajo; parent:: (..) y ancestor:: buscan hacia arriba.
  • following-sibling:: y preceding-sibling:: se quedan en un nivel, que es como se dice "el price que va después de este title".
  • attribute:: se escribe @, self:: se escribe . en una expresión abreviada.
  • namespace:: está en la especificación pero Firefox no lo implementa. No construyas sobre él.

Lo que XPath 1.0 no tiene

Los navegadores implementan XPath 1.0 y nada más. La API vino de DOM Level 3 XPath, hoy una Nota del W3C retirada, y pervive en la sección 8 del estándar DOM del WHATWG. Ningún navegador tiene 2.0 y ninguno lo va a tener, así que esta página declara el techo en vez de anunciar una versión que no puede entregar.

XPath 1.0 tiene cuatro tipos: conjunto de nodos, cadena, número y booleano. 2.0 sustituyó ese modelo por secuencias y tipado consciente del esquema, y trajo lo que más se echa de menos: matches(), replace() y tokenize(), tipos de fecha reales, expresiones for e if. 3.1 añadió mapas, arrays y la flecha =>. Todo eso falla aquí, e igualmente en DOMXPath de PHP, XPathNavigator de .NET y javax.xml.xpath de Java, que también son 1.0. Los apaños que más usarás son substring-before y substring-after en lugar de tokenize, y translate() en lugar de una clase de caracteres.

Leer el resultado

No toda expresión devuelve nodos. count(//book) devuelve un número y string(/catalog/@id) devuelve una cadena, así que el panel dice cuál de los cuatro tipos ha vuelto e imprime los escalares como valores en vez de como lista vacía. Un 0 escalar y un conjunto de nodos vacío se parecen en la mayoría de herramientas y significan cosas distintas.

Cada coincidencia muestra su tipo de nodo (elemento, atributo, texto, cdata, comentario, instrucción de procesamiento), la ruta que llega hasta ella y su valor: XML serializado para un elemento, el valor del atributo para un atributo. El contenido coincidente se escribe en la página como texto y nunca como marcado, así que un documento que contenga un elemento script no puede ejecutar nada. El documento debe estar bien formado antes de que una expresión se ejecute, un prefijo sin vincular se informa por su nombre junto a los prefijos disponibles, y se renderizan las primeras 1.000 coincidencias mientras que el recuento sobre la lista es el total real.

Hacer esto desde código

Los seis implementan XPath 1.0, y los seis te obligan a registrar tú mismo los prefijos de espacio de nombres. Los prefijos del propio documento nunca se recogen automáticamente, y por eso el argumento de espacios de nombres aparece en todos los ejemplos.

const source = `<?xml version="1.0"?>
<library xmlns="urn:books">
  <book id="b1"><title>XML in a Nutshell</title></book>
</library>`;

const doc = new DOMParser().parseFromString(source, 'application/xml');
if (doc.querySelector('parsererror')) throw new Error('not well-formed');

// document.createNSResolver is deprecated: it now returns its input
// unchanged and is kept only for compatibility. Write the resolver
// yourself. These prefixes are local to the expression and need not
// match the ones the document uses.
const NS = { bk: 'urn:books' };
const resolve = (prefix) => NS[prefix] ?? null;

// Snapshot, not iterator: an iterator result is invalidated by any
// mutation of the document while you are still walking it.
const snap = doc.evaluate(
  '//bk:book[@id="b1"]/bk:title',   // //title would match nothing
  doc,
  resolve,
  XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,
  null,
);

for (let i = 0; i < snap.snapshotLength; i++) {
  console.log(snap.snapshotItem(i).textContent);
}
from lxml import etree

# lxml's defaults resolve entities and fetch external DTDs. All three
# flags below are needed to close that off.
parser = etree.XMLParser(resolve_entities=False, no_network=True, load_dtd=False)
tree = etree.fromstring(source.encode('utf-8'), parser)

# lxml refuses a None key in the namespaces map, for the same reason the
# spec does: XPath 1.0 cannot address a default namespace. Give it a prefix.
ns = {'bk': 'urn:books'}

for title in tree.xpath('//bk:book/bk:title', namespaces=ns):
    print(title.text)

# An invalid expression raises rather than returning empty.
try:
    tree.xpath('//bk:book[')
except etree.XPathEvalError as e:
    print(f'bad expression: {e}')

# The escape hatch when you cannot register prefixes:
tree.xpath("//*[local-name()='book']")
import javax.xml.XMLConstants;
import javax.xml.namespace.NamespaceContext;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.xpath.*;
import org.w3c.dom.NodeList;
import java.util.Iterator;
import java.util.Map;

DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();

// setNamespaceAware is FALSE by default. Leave it off and every prefixed
// element is treated as one long local name, so bk:book never matches.
// This is the usual cause of "it works in xmllint but not in Java".
dbf.setNamespaceAware(true);
dbf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);

var doc = dbf.newDocumentBuilder()
    .parse(new java.io.ByteArrayInputStream(bytes));

XPathFactory xpf = XPathFactory.newInstance();
xpf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
XPath xpath = xpf.newXPath();

Map<String, String> ns = Map.of("bk", "urn:books");
xpath.setNamespaceContext(new NamespaceContext() {
    public String getNamespaceURI(String prefix) {
        return ns.getOrDefault(prefix, XMLConstants.NULL_NS_URI);
    }
    public String getPrefix(String uri) { return null; }
    public Iterator<String> getPrefixes(String uri) { return null; }
});

NodeList nodes = (NodeList) xpath.evaluate(
    "//bk:book/bk:title", doc, XPathConstants.NODESET);

for (int i = 0; i < nodes.getLength(); i++) {
    System.out.println(nodes.item(i).getTextContent());
}
using System.Xml;
using System.Xml.XPath;

var settings = new XmlReaderSettings
{
    DtdProcessing = DtdProcessing.Prohibit,
    XmlResolver = null,
};

using var reader = XmlReader.Create(new StringReader(source), settings);
var nav = new XPathDocument(reader).CreateNavigator();

// XmlNamespaceManager is not optional. .NET has no default-namespace
// concept in XPath either, so bind a prefix and use it.
var ns = new XmlNamespaceManager(nav.NameTable);
ns.AddNamespace("bk", "urn:books");

foreach (XPathNavigator node in nav.Select("//bk:book/bk:title", ns))
{
    Console.WriteLine(node.Value);
}

// Compile once if the expression is reused: Select() reparses every call.
XPathExpression expr = nav.Compile("count(//bk:book)");
expr.SetContext(ns);
Console.WriteLine((double)nav.Evaluate(expr));
<?php
$doc = new DOMDocument();
// LIBXML_NONET stops libxml2 fetching anything the document references.
if (!$doc->loadXML($source, LIBXML_NONET)) {
    fwrite(STDERR, "not well-formed\n");
    exit(1);
}

$xpath = new DOMXPath($doc);

// Prefixes must be registered even when the document declares them.
// registerNamespace('', ...) is accepted but useless: XPath 1.0 still
// cannot address the empty prefix.
$xpath->registerNamespace('bk', 'urn:books');

foreach ($xpath->query('//bk:book/bk:title') as $node) {
    echo $node->textContent, "\n";
}

// query() returns false on an invalid expression, not an empty list.
// A loose == comparison would read that false as "no results".
$result = $xpath->query('//bk:book[');
if ($result === false) {
    fwrite(STDERR, "invalid XPath expression\n");
}
# xmllint ships with libxml2 and is almost certainly already installed.
# --xpath has no way to register a namespace prefix, so against a
# namespaced document it prints "XPath set is empty" and exits 10.
xmllint --nonet --xpath '//book/title' doc.xml

# The interactive shell does have setns, and reads fine from a heredoc:
xmllint --nonet --shell doc.xml <<'EOF'
setns bk=urn:books
xpath //bk:book/bk:title
EOF

# Or sidestep prefixes entirely:
xmllint --nonet --xpath "//*[local-name()='title']/text()" doc.xml

# xmlstarlet is the friendlier option if you can install it:
xmlstarlet sel -N bk=urn:books -t -v '//bk:book/bk:title' -n doc.xml

Dos cosas se repiten en los seis. Los prefijos de espacio de nombres los declaras tú, no los aporta el documento, y una expresión inválida se señala de forma distinta en cada uno (una DOMException lanzada, un XPathEvalError elevado, un false devuelto, un estado de salida 10), y por eso ninguno de estos ejemplos llama a la consulta y usa su resultado en una sola línea.

Preguntas frecuentes

¿Por qué mi XPath no devuelve resultados?

Normalmente porque el documento tiene un espacio de nombres por defecto y tu expresión no lleva prefijo. Si la raíz lleva xmlns="urn:something", el book de dentro es en realidad {urn:something}book, y //book pide un book sin espacio de nombres. Cero coincidencias y ningún error, porque desde el punto de vista de XPath no ha ido nada mal.

Esta página lo detecta, nombra la URI que ha encontrado, y le vincula el prefijo ns para que //ns:book funcione de inmediato. Si prefieres evitar prefijos, //*[local-name()="book"] coincide por el nombre local independientemente del espacio de nombres. Las otras causas que descartar son las mayúsculas, ya que XPath distingue, y un predicado [0] cuando los índices empiezan en 1.

¿Se sube mi XML cuando pruebo una expresión?

No. El escaneo de buena formación corre en un Web Worker de esta pestaña y la expresión la evalúa document.evaluate, una API local del navegador. Aquí no hay componente de servidor al que enviar nada.

Eso importa más en XPath que en la mayoría de herramientas, porque los documentos contra los que se escriben expresiones son payloads reales y no muestras: una respuesta capturada de la API de un socio, un mensaje sacado de una cola, una aserción SAML. Abre la pestaña Red, pega un documento, ejecuta una expresión, y míralo seguir vacío.

¿Qué versión de XPath admite?

XPath 1.0, porque es lo que implementan los navegadores y no hay alternativa. La evaluación corre sobre document.evaluate, definido en la sección 8 del estándar DOM del WHATWG. Chrome, Firefox y Safari son todos 1.0 y ninguno ha anunciado intención de ir más allá.

Así que matches(), replace(), tokenize(), las expresiones for e if, los tipos de fecha, las secuencias, los mapas y los arrays fallan todos aquí, igual que en DOMXPath de PHP, XPathNavigator de .NET y javax.xml.xpath de Java. Si necesitas 2.0 o 3.1, eso significa Saxon: Saxon-JS en el navegador, Saxon-HE en la JVM o .NET.

¿Cuál es la diferencia entre / y // en XPath?

/ selecciona un hijo directo; // es abreviatura de /descendant-or-self::node()/ y selecciona a cualquier profundidad. Así que /catalog/book coincide con los elementos book inmediatamente dentro de la raíz catalog, mientras que //book coincide con book en cualquier sitio. Una / inicial ancla en la raíz del documento, y por eso /book falla en un documento cuya raíz es catalog.

La trampa es combinar // con un predicado. //book[1] no significa "el primer book del documento"; el predicado se aplica al paso, así que significa "todo book que sea el primer hijo book de su padre", y tres catálogos te dan tres nodos. Envuélvelo para obtener lo que querías decir: (//book)[1].

¿Cómo selecciono un atributo en vez de un elemento?

Pon @ delante del nombre. //book/@id selecciona el nodo atributo id y el panel muestra su valor, la ruta a la que pertenece y su tipo. Para filtrar por un atributo en lugar de seleccionarlo, ponlo en un predicado: //book[@id="b1"] selecciona elementos book, no atributos.

Los atributos tienen su propia regla de espacios de nombres, y es la que la gente se equivoca. Una declaración de espacio de nombres por defecto nunca se aplica a los nombres de atributo, así que en <book xmlns="urn:books" id="7"/> el atributo es simplemente {}id: selecciónalo como @id, porque @ns:id no coincide con nada. Un atributo solo tiene espacio de nombres cuando escribes tú el prefijo, como en xlink:href o xsi:schemaLocation.

¿Puedo probar XPath contra HTML aquí?

Solo si el HTML es XML bien formado, cosa que la mayoría no es. La entrada se analiza como application/xml, así que las etiquetas br sin cerrar, los valores de atributo sin comillas y los ampersands sueltos en URLs se rechazan antes de que se ejecute ninguna expresión. Eso es deliberado: XPath se comporta de forma distinta contra un DOM de HTML, donde los nombres de elemento se pasan a minúsculas y todo está en el espacio de nombres de XHTML.

Contra un XMLDocument estricto, como aquí, las pruebas de nodo distinguen mayúsculas y los espacios de nombres se comportan como dice la especificación. Para HTML del mundo real usa un analizador tolerante: lxml.html en Python, jsoup en Java, o querySelector cuando baste un selector CSS. XHTML, SVG y un fragmento que ya hayas arreglado se analizan aquí sin problema.

Herramientas relacionadas

Lecturas de referencia

Errores que esto resuelve