DOMDocument en PHP: parsear HTML, manipular el DOM y extraer datos

DOMDocument es la extensión de PHP que implementa la especificación DOM de W3C. A diferencia de SimpleXML, DOMDocument puede trabajar con HTML malformado (mediante loadHTML()), ofrece control completo sobre los nodos del árbol y resulta imprescindible para scraping, generación de HTML dinámico o modificaciones quirúrgicas de documentos.

Cargar HTML y XML

<?php
$dom = new DOMDocument('1.0', 'UTF-8');
$dom->preserveWhiteSpace = false;
$dom->formatOutput = true;

// Desde string HTML (soporta HTML malformado)
libxml_use_internal_errors(true);
$dom->loadHTML('<!DOCTYPE html><html><body><p>Hola<p>Mundo</body></html>');
libxml_clear_errors();

// Desde fichero HTML
$dom->loadHTMLFile('pagina.html');

// XML bien formado
$dom->loadXML('<?xml version="1.0"?><root><item>Texto</item></root>');

// Con encoding correcto para HTML en UTF-8
libxml_use_internal_errors(true);
$dom->loadHTML('<!DOCTYPE html><meta charset="UTF-8">' . $html_utf8);

Ejemplo 1: extraer precios de una página web

<?php
function extraerPrecios(string $html): array {
    $dom = new DOMDocument();
    libxml_use_internal_errors(true);
    $dom->loadHTML($html);
    libxml_clear_errors();

    $xpath = new DOMXPath($dom);
    // Seleccionar elementos con clase "precio"
    $nodos = $xpath->query('//*[contains(@class, "precio")]');

    $precios = [];
    foreach ($nodos as $nodo) {
        $texto = trim($nodo->textContent);
        // Extraer el número del texto (ej: "89,99 €" ? 89.99)
        if (preg_match('/[d.,]+/', $texto, $m)) {
            $precios[] = (float) str_replace(',', '.', $m[0]);
        }
    }
    return $precios;
}

$html = file_get_contents('https://ejemplo.com/productos');
$precios = extraerPrecios($html);
echo "Precio mínimo: " . min($precios) . " €";

DOMXPath: consultas sobre el documento

<?php
$dom = new DOMDocument();
libxml_use_internal_errors(true);
$dom->loadHTML(file_get_contents('https://ejemplo.com'));
libxml_clear_errors();

$xpath = new DOMXPath($dom);

// Todos los enlaces
$enlaces = $xpath->query('//a[@href]');
foreach ($enlaces as $a) {
    echo $a->getAttribute('href') . ' — ' . $a->textContent . "n";
}

// El primer h1
$h1 = $xpath->query('//h1')->item(0);
echo $h1 ? $h1->textContent : 'Sin título';

// Imágenes con alt vacío (accesibilidad)
$sinAlt = $xpath->query('//img[not(@alt) or @alt=""]');
echo "Imágenes sin alt: " . $sinAlt->length;

// Párrafos dentro de un div concreto
$parrafos = $xpath->query('//div[@id="contenido"]//p');
foreach ($parrafos as $p) {
    echo $p->textContent . "n";
}

Ejemplo 2: limpiar HTML de usuario

Eliminar etiquetas peligrosas (script, iframe, atributos on*) manteniendo el resto del marcado:

<?php
function limpiarHTML(string $html, array $permitidas = ['p', 'a', 'strong', 'em', 'ul', 'ol', 'li', 'br']): string {
    $dom = new DOMDocument();
    libxml_use_internal_errors(true);
    $dom->loadHTML('<div id="_root">' . $html . '</div>', LIBXML_HTML_NOIMPLIED);
    libxml_clear_errors();

    $xpath = new DOMXPath($dom);

    // Eliminar script, style, iframe y etiquetas no permitidas
    $peligrosas = $xpath->query(
        '//*[not(self::' . implode(' or self::', $permitidas) . ')]'
        . '[not(self::html or self::body or self::div[@id="_root"])]'
    );
    foreach (iterator_to_array($peligrosas) as $nodo) {
        // Reemplazar el nodo por sus hijos de texto
        $padre = $nodo->parentNode;
        while ($nodo->firstChild) {
            $padre->insertBefore($nodo->firstChild, $nodo);
        }
        $padre->removeChild($nodo);
    }

    // Eliminar atributos on* (onclick, onload, etc.)
    foreach ($xpath->query('//@*[starts-with(name(), "on")]') as $attr) {
        $attr->ownerElement->removeAttributeNode($attr);
    }

    $root = $dom->getElementById('_root');
    $resultado = '';
    foreach ($root->childNodes as $hijo) {
        $resultado .= $dom->saveHTML($hijo);
    }
    return trim($resultado);
}

Ejemplo 3: construir HTML dinámicamente

<?php
function generarTabla(array $datos, array $cabeceras): string {
    $dom = new DOMDocument('1.0', 'UTF-8');

    $tabla = $dom->createElement('table');
    $tabla->setAttribute('class', 'tabla-datos');

    // Cabecera
    $thead = $dom->createElement('thead');
    $tr = $dom->createElement('tr');
    foreach ($cabeceras as $cab) {
        $th = $dom->createElement('th', htmlspecialchars($cab));
        $tr->appendChild($th);
    }
    $thead->appendChild($tr);
    $tabla->appendChild($thead);

    // Cuerpo
    $tbody = $dom->createElement('tbody');
    foreach ($datos as $fila) {
        $tr = $dom->createElement('tr');
        foreach ($fila as $celda) {
            $td = $dom->createElement('td', htmlspecialchars((string)$celda));
            $tr->appendChild($td);
        }
        $tbody->appendChild($tr);
    }
    $tabla->appendChild($tbody);
    $dom->appendChild($tabla);

    return $dom->saveHTML($tabla);
}

$productos = [
    ['Teclado', 89.99, 42],
    ['Ratón',   29.99, 150],
];
echo generarTabla($productos, ['Producto', 'Precio', 'Stock']);

Ejemplo 4: leer un feed RSS con namespaces

<?php
function parsearRSS(string $url): array {
    $dom = new DOMDocument();
    libxml_use_internal_errors(true);
    $dom->load($url);
    libxml_clear_errors();

    $xpath = new DOMXPath($dom);
    $xpath->registerNamespace('media', 'http://search.yahoo.com/mrss/');
    $xpath->registerNamespace('dc',    'http://purl.org/dc/elements/1.1/');

    $items = [];
    foreach ($xpath->query('//item') as $item) {
        $imagen = $xpath->query('media:thumbnail/@url', $item)->item(0);
        $autor  = $xpath->query('dc:creator', $item)->item(0);
        $items[] = [
            'titulo'  => $xpath->query('title', $item)->item(0)?->textContent,
            'enlace'  => $xpath->query('link',  $item)->item(0)?->textContent,
            'fecha'   => $xpath->query('pubDate', $item)->item(0)?->textContent,
            'imagen'  => $imagen?->nodeValue,
            'autor'   => $autor?->textContent,
        ];
    }
    return $items;
}

Modificar y guardar el documento

<?php
$dom = new DOMDocument();
$dom->loadHTMLFile('pagina.html');
$xpath = new DOMXPath($dom);

// Añadir clase a todos los h2
foreach ($xpath->query('//h2') as $h2) {
    $claseActual = $h2->getAttribute('class');
    $h2->setAttribute('class', trim($claseActual . ' seccion'));
}

// Insertar un nodo nuevo
$aviso = $dom->createElement('div');
$aviso->setAttribute('class', 'aviso');
$aviso->textContent = 'Contenido actualizado';
$body = $dom->getElementsByTagName('body')->item(0);
$body->insertBefore($aviso, $body->firstChild);

// Eliminar un nodo
$anuncios = $xpath->query('//div[@id="publicidad"]');
foreach (iterator_to_array($anuncios) as $nodo) {
    $nodo->parentNode->removeChild($nodo);
}

// Guardar
$dom->saveHTMLFile('pagina_modificada.html');
echo $dom->saveHTML();  // string

Errores frecuentes

  • No usar libxml_use_internal_errors(true): el HTML real casi siempre tiene errores que DOMDocument reporta con warnings. Silenciarlos con esta función es la práctica estándar.
  • Modificar una NodeList mientras se itera: eliminar nodos en un foreach de DOMNodeList produce comportamientos inesperados. Usa iterator_to_array() primero.
  • textContent vs nodeValue: textContent devuelve el texto completo incluyendo descendientes; nodeValue solo el del nodo actual. Para elementos de texto, usa textContent.
  • Encoding en loadHTML: si el HTML no declara charset UTF-8, DOMDocument puede malinterpretar caracteres. Añade <meta charset="UTF-8"> al principio del string o usa mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8').

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP