DEV Community

Cover image for Extraindo o XPath de um elemento no navegador
Nilton
Nilton

Posted on • Updated on

Extraindo o XPath de um elemento no navegador

(Ilustração da capa por Ilya Nazarov em ArtStation)

Porque eu deveria me importar

Todo mundo que manipula elementos HTML está familiarizado com métodos como querySelector(), querySelectorAll(), ou outros antigos e ainda suportados como getElementById() e variações. A ideia deles é encontrar elementos a partir de seletores como classes CSS, ids, nomes das tags, entre outros. Algumas vezes, é preciso encontrar um seletor universal, algo que identifique especificamente qual é o elemento. O XPath pode ser uma alternativa pra isso.

O que é XPath

Se pensarmos na DOM como uma estrutura em árvore com suas muitas divisões e galhos, seria possível localizar qualquer ponto nela indicando o caminho a ser percorrido. Pra exemplificar a metáfora da árvore: se pensarmos nas centenas ou milhares de folhas que uma árvore pode ter, seria muito complicado apontar pra alguém uma determinada folha dizendo "é aquela verde" ou "a que tá virada pra cima". É muito mais preciso dizer "depois do segundo galho que vem do tronco, tem outros dois menores, e uns ramos... é a folha que tá no primeiro". De forma muita rasa e concisa, o XPath é esse caminho, só que pra árvore do DOM. Considere o trecho HTML a seguir:

<!DOCTYPE html>
<html>
  <head>
    <!-- ... -->
  </head>

  <body>
    <div>
      <span>
        <!-- ... -->
      </span>
      <span>
        <!-- ... -->
      </span>
    </div>
    <div>
      <span>
        <!-- ... -->
      </span>
      <span>
        <!-- ... -->
      </span>
    </div>
    <div>
      <span>
        <!-- ... -->
      </span>
      <span>
        <!-- ... -->
      </span>
    </div>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

Se quiséssemos pegar, digamos, algum span dentro de um div, não teríamos exatamente uma maneira precisa de dizer qual elemento queremos, pois os métodos citados lá atrás retornariam listas de elementos. Esses elementos também não tem seletores específicos como classes ou atributos HTML. Se quisesse o segundo span do terceiro div, por exemplo, teríamos que dizer "segundo span, dentro do terceiro div, dentro do body".
É aí que o XPath entra em ação, ele é literalmente isso:

/html/body/div[3]/span[2]

É uma notação muito familiar, muito parecido com árvores de diretórios, seria algo como "dentro de html, dentro de body, dentro do terceiro div, selecione o segundo span. O mais interessante é que seria uma espécie de seletor universal. É uma notação interessante que pode ser comunicada entre plataformas, guardada na aplicação pra algum uso futuro, replicada em algum outro momento. Existem inúmeros problemas específicos que o XPath soluciona justamente pela flexibilidade de uso.

É precisar observar que, diferentemente da indexação de arrays em JS, num XPath ela começa no 1 e não no 0, ou seja, o segundo elemento vai ser achado pelo índice [2] e não [1]. Ao lidar com os arrays dos elementos no código, é preciso compensar essa diferença.

Se você quiser entender a fundo mesmo, recomendo olhar a documentação oficial. Ela talvez seja demais pra esse artigo, mas vale a pena ao fim dele entrar e tentar descobrir novas formas de implementar o que tá descrito lá.
Por padrão, os navegadores não implementam um método de encontrarmos o XPath de um elemento, por isso temos que pensar numa maneira de, quando precisarmos, implementar a lógica por trás pra geração desse caminho.

Implementação básica

Pensando no código

Bom, o raciocínio inicial é: percorrer a árvore do DOM até o elemento raiz (html) e ir montando o nosso XPath de acordo. Pra isso, eu resolvi colocar toda a lógica dentro de uma só função, que recebe o elemento e retorna o XPath como string. Sem muito mistério, direto ao ponto.

const getXPath = (element) => {
  // 
}
Enter fullscreen mode Exit fullscreen mode

Na função, achei que seria interessante separar o processo em duas etapas: 1) coletar todos os elementos da árvore de ascendência, a partir do elemento inicial até o html, e depois 2) montar a partir disso o XPath. Seguindo o raciocínio da primeira parte:

  // Array que vai guardar os elementos ascendentes em ordem
  const ascendingElements = []

  // Guarda o primeiro elemento logo de cara, já que ele obviamente faz parte do XPath
  ascendingElements.push(element)

  // Estrutura do/while, que executa a iteração enquanto houver elementos pai
  do {
   ascendingElements.unshift(ascendingElements[0].parentElement)
  } while (ascendingElements[0].parentElement !== null)
Enter fullscreen mode Exit fullscreen mode

Dentro do do..while o que acontece é: verificamos se o primeiro elemento do array tem um pai válido (não-nulo). Se tem, adiciona ele a mesma lista no começo usando o método unshift().

Nota: Dá pra usar aqui o método push() também, só lembrando que ele insere o item no fim do array, então sempre teríamos que verificar qual é o último elemento utilizando length - 1 do próprio array, e depois teríamos que inverter a ordem do array completo pra montar o XPath corretamente. Não tem problema nenhum, só considerei que seria um pouco mais legível assim.

Ao atingirmos um parentElement igual a null, significa que atingimos o fim do documento, ou seja, o próprio html, já que ele não tem elemento pai. O loop então se encerra e teremos no array ascendingElements todos os elementos em ordem.
A partir de então podemos trabalhar na criação do XPath em si. Todos os elementos podem ter seu nome acessado através da propriedade tagName e facilmente poderemos percorrer o array concatenando os nomes:

  // Criamos um novo array através de .map() iterando sobre os elementos e retornando só os seus nomes
  const orderedTagNames = ascendingElements.map(element => element.tagName)
  // Podemos juntar todas as strings, colocando entre elas uma "/" e transformando tudo em minúscula, já que `tagName` retorna o nome em maiúsculo.
  const xPath = orderedTagNames.join('/').toLowerCase()

  // A função retorna a string completa concatenada a uma barra inicial, indicando que ali é a raiz do documento.
  return `/${xPath}`
Enter fullscreen mode Exit fullscreen mode

A função completa seria então

const getXPath = (element) => {
  const ascendingElements = []
  ascendingElements.push(element)

  do {
    ascendingElements.unshift(ascendingElements[0].parentElement)
  } while (ascendingElements[0].parentElement !== null)

  const orderedTagNames = ascendingElements.map(element => element.tagName)
  const xPath = orderedTagNames.join('/').toLowerCase()

  return `/${xPath}`
}
Enter fullscreen mode Exit fullscreen mode

Temos a função completa! Ela retorna o XPath de um elemento completo. Vamos aplicar ao exemplo do início do texto. Se formos tentar criar o XPath do segundo span do terceiro elemento div, por exemplo, teremos

/html/body/div/span

À primeira vista tudo certo, porém não temos a indicação da ordem do elemento! Se fossemos seguir esse XPath gerado, pegaríamos o primeiro span dentro do primeiro div. O nosso código não leva em conta que podem haver elementos de mesmo nome filhos do mesmo pai. O certo nesse exemplo seria indicar que era o span[2] depois de div[3], de acordo com a especificação. Pra resolver isso, poderíamos então verificar em qual posição o elemento filho está relativa ao seus similares.

  // Vamos retornar o nome dos elementos já com a indicação da sua posição
  const orderedTagNames = ascendingElements.map((element, elementLevel) => {
    const elementSelector = element.tagName

    // Um contador pra guardar, dentro de cada novo elemento que estamos verificando, em qual ordem ele está entre os seus similires
    let sameTagNameCounter = 0

    if (ascendingElements[elementLevel - 1] !== undefined) {
      for (let child of ascendingElements[elementLevel - 1].children) {

        // Se o elemento tem nome igual, adicionamos uma unidade ao seu contador. Ele servirá pra montarmos o nome com a posição correta ao fim do loop
        if (elementSelector === child.tagName) {
          sameTagNameCounter++ 
        }

        // Não precisamos conhecer quais são todos os elementos filhos em comum, precisamos encontrar somente a posição do elemento atual e depois disso podemos encerrar o loop
        if (element === child) {
          break
        }
      }
    }

    // Aplica a formatação "nomeDaTag[posição]" caso tenhamos mais de um elemento
    return `${elementSelector}${sameTagNameCounter > 1 ? `[${sameTagNameCounter}]` : ''}`
  })
Enter fullscreen mode Exit fullscreen mode

Agora sim, se executarmos a função com o mesmo exemplo, teremos o resultado correto.

/html/body/div[3]/span[2]

Melhoramentos

Existem muitas maneiras de implementar essa lógica. Essa é mais uma sugestão simplificada do que regra, mas que poderia ter sido feita de outras formas. Poderíamos usar recursividade e diminuir algumas linhas de código? Com certeza. A manipulação dos elementos poderia ter sido feita com outros métodos? Há uma infinidade de maneiras de se abordar o mesmo problema, e contanto que resolva e siga boas práticas, tá tudo bem. Poderíamos desestruturar essa função em duas ou mais? Se estivéssemos em produção eu diria que deveríamos.

Mas não só do ponto de vista técnico, mas também do funcional. O XPath é uma notação extremamente robusta: é possível também usar funções pra selecionar algum id específico, acessar via seletores CSS, atributos e mais uma infinidade de coisas. Tem uma colinha bem legal aqui, recomendo.
O código que a gente trabalhou é funcional, mas também é bem básico. Pra solucionar problemas mais complexos ou cenários mais robustos, considere pesquisar alguma biblioteca já bem estabelecida que resolva esses problemas.

Top comments (0)