Un parser HTML des plus léger

Jusqu'à présent, WdPublisher (mon bébé CMS) traitait les gabarits utilisés pour la construction des sites à grands coups de preg_match_callback(), faisant croire que tout allait bien. Malheureusement, aussi merveilleuses que puissent être les expression rationnelles, leur utilisation a vite posé des limites qu'il fallait contourner par des tours de magie contraignants.

Ce matin, profitant du calme d'un jour férié, je me suis assis devant mon ordi et j'ai réfléchi très fort (aïe) en essayant de tout reprendre like a virgin.

Impossible récursion

Le problème de la méthode récursive en utilisant preg_match_callback() c'est que rien ne va plus dès que deux mêmes marqueurs s’emboîtent :

<div>
    <span>
        <br />
        <span>
        un bout de texte
        </span>
        <input type="text" />
    </span>
</div>

Dans l'exemple ci-dessus, si l'on cherche à récupérer le contenu du premier span on va se retrouver avec :

<span>
        <br />
        <span>
        un bout de texte
        </span>

alors que tout ce l'on voulait c'était :

<span>
        <br />
        <span>
        un bout de texte
        </span>
        <input type="text" />
    </span>

Je ne vais même pas parler du cas où les tags ne sont pas correctement fermés, c'est totalement le bordel :-(

Que faire alors ? Renoncer ? Partir à la plage ? Feindre l'ignorance jusqu'à ce qu'on se moque de moi qui suis sois-disant exigeant. Je me devais de sauver ma réputation, sinon ma vie de développeur serait foutue et je n'aurai d'autre choix que de sortir tous les samedi soir pour boire mon salaire en compagnie fluorescente.

De la lumière et un arbre

C'est quand même pas compliqué ! Il me faut un arbre. Avec un arbre de marqueurs la vie serait tellement plus belle et facile ! Mais comment le construire ? Hum. Ben avec pas grand chose finalement. J'ai juste besoin de savoir quand un marqueur s'ouvre et quand il se ferme, en gérant les marqueurs qui se ferment eux mêmes, en oubliant pas de récupérer le texte qui traîne autour de tout ça.

« Autour de tout ça »

Mais bien sur ! La solution était là, palpitante, dans mon cerveau brumeux mais finalement assez lucide pour un vendredi matin. Plutôt que d'utiliser preg_match_callback() et suer sang et eau à gérer ses retours, pourquoi ne pas séparer les marqueurs du texte, dans une sorte de joli tableau texte / marqueur. Je n'aurai alors plus qu'à créer un véritable arbre en fonction des marqueurs d'ouverture et de fermeture !

Séparer le blanc du jaune

Pour séparer les marqueurs du texte, nous allons rester dans les expressions rationnelles en utilisant la fabuleuse, et finalement méconnue fonction preg_split(). C'est assez simple de récupérer les marqueurs HTML. Un petit coup de #<(/?)([^>]*)># fait l'affaire. L'important c'est de conserver le marqueur délimiteur.

<?php

$matches = preg_split('#<(/?)([^>]*)>#'$template-1, PREG_SPLIT_DELIM_CAPTURE);

Et voilà le résultat, stupéfiant :

Array
(
    [0] => 
    [1] => 
    [2] => div

    [3] =>     
    [4] => 
    [5] => span

    [6] =>         
    [7] => 
    [8] => br /

    [9] =>         
    [10] => 
    [11] => span

    [12] => 
        un bout de texte        
    [13] => /
    [14] => span

    [15] =>         
    [16] => 
    [17] => input type="text" /

    [18] =>     
    [19] => /
    [20] => span

    [21] => 
    [22] => /
    [23] => div

    [24] => 
)

J'ai associé les entrées par groupe de trois pour que l'on voit mieux ce qui se passe. Si l'on prend i comme index de départ, alors :

  • en i+0 nous avons le texte qui se trouve avant le marqueur HTML.
  • en i+1 nous avons '/' si le marqueur est un marqueur de fermeture.
  • en i+2 nous avons le contenu du marqueur, accompagné de sa barre de fraction / pour les marqueurs qui se ferment eux-mêmes.

Il ne nous reste plus qu'à faire une jolie boucle sur tout ce beau monde en utilisant un modulo de 3 pour récupérer toutes les informations dont nous avons besoin pour créer un arbre.

Construction de l'arbre

Maintenant que nous avons établi les bases de la séparation, il est tant de rassembler tout ça sous la forme bien plus pratique d'un arbre de nœuds texte et marqueur.

Comme nous l'avons vu, i % 3 == 0 nous permet de récupérer le texte qui se trouve avant le marqueur. On en fera un joli nœud texte. i % 3 == 1 nous permet de savoir si le marqueur qui suit est un marqueur de fermeture, auquel cas on trouvera un '/'. Voyons maintenant ce qui se passe lorsque l'on tombe enfin sur le contenu du marqueur en i % 3 == 2.

Comme nous allons faire pas mal de récursion, le mieux est de sortir chaque entrée du tableau jusqu'à ce qu'il n'y en ait plus. Le tableau doit rester accessible de façon à ce qu'au retour d'une récursion on puisse poursuivre la collecte tranquillement. Ainsi :

  • si le marqueur se ferme lui-même on crée un nœud marqueur et on continu la collecte.
  • si le marqueur est un marqueur de fermeture on retourne la branche. Vous verrez dans le code final qu'à ce point je vérifie que le marqueur de fermeture correspond bien au dernier marqueur ouvert. Structure bien formée, quand tu nous tiens.
  • enfin, si le marqueur ne correspond à aucun des cas précédents, il s'agit d'un marqueur ouvert. On crée donc un nœud marqueur avec un champ enfants et l'on fait une récursion sur ce qui reste des entrées du tableau.

On devrait alors obtenir un bel arbre comme celui-ci :

Array
(
    [0] => Array
        (
            [name] => div
            [args] => Array
                (
                )

            [children] => Array
                (
                    [0] => Array
                        (
                            [name] => span
                            [args] => Array
                                (
                                )

                            [children] => Array
                                (
                                    [0] => Array
                                        (
                                            [name] => br
                                            [args] => Array
                                                (
                                                )

                                        )

                                    [1] => Array
                                        (
                                            [name] => span
                                            [args] => Array
                                                (
                                                )

                                            [children] => Array
                                                (
                                                    [0] => 
        un bout de texte
        
                                                )
                                        )

                                    [2] => Array
                                        (
                                            [name] => input
                                            [args] => Array
                                                (
                                                    [type] => text
                                                )
                                        )
                                )
                        )
                )
        )
)

La classe WdHTMLParser

Ne reste plus qu'à fignoler en gérant les commentaires et autre marqueurs d'instruction et le tour et joué !

Voici mesdames et messieurs, devant vos yeux émerveillés, la classe WdHTMLParser qui, je l'espère, vous sera aussi utile qu'à moi.

<?php

class WdHTMLParser
{
    private $encoding;
    private $matches;
    private $escaped;
    private $opened = array();
    
    public $malformed;

    public function parse($html$namespace=NULL$encoding='utf-8')
    {
        $this->malformed = false;
        $this->encoding = $encoding;
        
        #
        # we take care of escaping comments and processing options. they will not be parsed
        # and will end as text nodes
        #
        
        $html = $this->escapeSpecials($html);
        
        #
        # in order to create a tree, we first need to split the HTML using the markups,
        # creating a nice flat array of texts and opening and closing markups.
        #
        # the array can be read as follows :
        #
        # i+0 => some text
        # i+1 => '/' for closing markups, nothing otherwise
        # i+2 => the markup it self, without the '<' '>'
        #
        # note that i+2 might end with a '/' indicating an auto-closing markup
        #
    
        $this->matches = preg_split
        (
            '#<(/?)' . $namespace . '([^>]*)>#'$html-1, PREG_SPLIT_DELIM_CAPTURE
        );
        
        #
        # the flat representation is now ready, we can create our tree
        #
        
        $tree = $this->buildTree();
        
        #
        # if comments or processing options where escaped, we can
        # safely unescape them now
        #
        
        if ($this->escaped)
        {
            $tree = $this->unescapeSpecials($tree);
        }
        
        return $tree;
    }
    
    private function escapeSpecials($html)
    {
        #
        # here we escape comments
        #
        
        $html = preg_replace_callback('#<\!--.+-->#sU'array($this'escapeSpecials_callback')$html);
        
        #
        # and processing options
        #
        
        $html = preg_replace_callback('#<\?.+\?>#sU'array($this'escapeSpecials_callback')$html);
        
        return $html;
    }
    
    private function escapeSpecials_callback($m)
    {
        $this->escaped = true;
        
        $text = $m[0];
        
        $text = str_replace
        (
            array('<''>'),
            array("\x01""\x02"),
            $text
        );
        
        return $text;
    }

    private function unescapeSpecials($tree)
    {
        return is_array($tree) ? array_map(array($this'unescapeSpecials')$tree) : str_replace
        (
            array("\x01""\x02"),
            array('<''>'),
            $tree
        );
    }

    private function buildTree()
    {
        $nodes = array();
            
        $i = 0;
        $text = NULL;
        
        while (($value = array_shift($this->matches)) !== NULL)
        {
            switch ($i++ % 3)
            {
                case 0:
                {
                    #
                    # if the trimed value is not empty we preserve the value,
                    # otherwise we discard it.
                    #
                    
                    if (trim($value))
                    {
                        $nodes[] = $value;
                    }
                }
                break;
                
                case 1:
                {
                    $closing = ($value == '/');
                }
                break;
                
                case 2:
                {
                    if (substr($value-11) == '/')
                    {
                        #
                        # auto closing
                        #
                        
                        $nodes[] = $this->parseMarkup(substr($value0-1));
                    }
                    else if ($closing)
                    {
                        #
                        # closing markup
                        #

                        $open = array_pop($this->opened);
                    
                        if ($value != $open)
                        {
                            $this->error($value$open);
                        }

                        return $nodes;
                    }
                    else
                    {
                        #
                        # this is an open markup with possible children
                        #
                        
                        $node = $this->parseMarkup($value);
                        
                        #
                        # push the markup name into the opened markups
                        #

                        $this->opened[] = $node['name'];
                        
                        #
                        # create the node and parse its children
                        #
                                                                        
                        $node['children'] = $this->buildTree($this->matches);
                        
                        $nodes[] = $node;
                    }
                }
            }
        }
        
        return $nodes;
    }
    
    public function parseMarkup($markup)
    {
        #
        # get markup's name
        #
        
        preg_match('#^[^\s]+#'$markup$matches);
        
        $name = $matches[0];
        
        #
        # get markup's arguments
        #
        
        preg_match_all('#\s+([^=]+)\s*=\s*"([^"]+)"#'$markup$matches, PREG_SET_ORDER);
        
        #
        # transform the matches into a nice key/value array
        #

        $args = array();
        
        foreach ($matches as $m)
        {
            #
            # we unescape the html entities of the argument's value
            #

            $args[$m[1]] = html_entity_decode($m[2], ENT_QUOTES, $this->encoding);
        }

        return array('name' => $name'args' => $args);
    }
    
    public function error($markup$expected)
    {
        $this->malformed = true;
        
        printf('unexpected closing markup "%s", should be "%s"'$markup$expected);
    }
}

Exemple d'utilisation

Ben c'est assez simple :

<?php

$parser = new WdHTMLParser();

$tree = $parser->parse($template);

echo '<pre>' . print_r($treetrue) . '</pre>';

Les espaces de noms

Parce que j'en ai besoin pour créer l'arbre des marqueurs de publication des gabarits pour WdPublisher, il est possible de spécifier l'espace de noms (ou namespace en Anglais) à utiliser lors de la recherche. Dans ce cas, seuls les marqueurs appartenant à l'espace de noms sont récupérés.

<wdp:articles limit="5">
    <ul>    
    <wdp:foreach>
        <li>{this.title}</li>
    </wdp:foreach>
    </ul>
</wdp:articles>

En utilisant l'espace de noms wdp: avec la gabarit ci-dessus nous obtenons l'arbre suivant :

<?php

$parser = new WdHTMLParser();

$tree = $parser->parse($template'wdp:');

echo '<pre>' . print_r($treetrue) . '</pre>';
Array
(
    [0] => Array
        (
            [name] => articles
            [args] => Array
                (
                    [limit] => 5
                )

            [children] => Array
                (
                    [0] => 
    <ul>    
    
                    [1] => Array
                        (
                            [name] => foreach
                            [args] => Array
                                (
                                )

                            [children] => Array
                                (
                                    [0] => 
        <li>{this.title}</li>
    
                                )
                        )

                    [2] => 
    </ul>

                )
        )
)

Pour conclure

On pouvait s'en douter les performances n'ont plus rien à voir. Dans les cas simples la publication est deux fois plus rapide. La publication est encore plus rapide lorsque un même gabarit est utilisé de nombreuses fois. En effet, puisque l'on peut mettre l'arbre en cache, plus besoin de traiter le gabarit à chaque appel !

Évidement la gestion des enfants, comme dans le cas choose/when/otherwise est d'autant plus simple, puisque tout est déjà prêt dans l'arbre.

Rha, les joies méconnues de la programmation :-D

Laisser un commentaire

19 commentaires

Ivan Enderlin
Ivan Enderlin

Hey :),

C'est pas mal mais j'ai quelques questions :

  1. pourquoi ne pas utiliser les fonctions de PHP qui sont appropriées (voir http://php.net/xml) ?
  2. pourquoi ne pas utiliser même SimpleXml (voir http://php.net/simplexml), quitte à faire un wrapper ?
  3. pourquoi ne gères-tu pas les CDATA ?

Pour les questions 1 et 2, tu gagnerais en rapidité. SimpleXML est totalement écrit en C, c'est donc plus rapide. D'autant qu'il permet la gestion du DOM, XPath, etc. De plus, SimpleXML est installé sur toutes les machines PHP 5 par défaut. Concernant les fonctions PHP pour la gestion des documents XML, elles sont également écrites en C (on l'aurait deviné ;-)) et sont même disponibles depuis PHP 4.

Olivier
Olivier

Merci de tes commentaires Ivan, mais comme le titre l'indique, il ne s'agit pas d'un parser XML, mais d'un parser HTML. J'ai besoin d'un parser HTML pour plus de souplesse avec l'arbre de marqueurs ainsi que les échappements.

Je me sers très souvent de SimpleXML pour parser mes fichiers XML, mais il m'a toujours manqué quelque chose de plus simple pour le HTML. C'est chose faite et pour le moment ça me satisfait.

Ivan Enderlin
Ivan Enderlin

Euh … mais l'HTML se manipule comme du XML :s. Faire le choix de n'utiliser uniquement que l'HTML écarte le problème des CDATA, je te l'accorde, mais pour le reste, je ne comprends pas.

Et si tu cherches quelques choses de plus simple, alors fait un wrapper de SimpleXML. C'est à dire : analyse ton document avec SimpleXML et transforme le résultat (objet) pour avoir ce qui te convient (tableau particulier dans ton cas).

Campings Sun Location
Campings Sun Location

Merci pour ce super parser HTML qui tourne très bien !!!

Très rapide et parfait :)

Dinosaurehonneur
Dinosaurehonneur

J'ai crée un parseur XML pour essayer de refaire un zCode-like grâce à DomXML … J'ai eu l'honneur que tout fonctionne bien sauf un truc que je n'arrive toujours pas à régler, les erreurs humaines.

Prenons le cas où l'utilisateur entre ceci : <gras>Texte en gras</gras>. Mon parseur va donc sortir ceci <strong>Texte en gras</strong>. Mais si l'utilisateur entre ceci : <gras>Texte en gras</gra> … Alors mon parseur ne pourra pas charger ce qu'a entré l'utilisateur car il y a une erreur. Et le problème ici, c'est que je n'arrive pas à gérer les erreurs de l'utilisateur. La seule chose que je peux dire, c'est que l'utilisateur a fait une erreur …

Donc, après cette mésaventure, je pense qu'il est plus judicieux d'utiliser la technique d'Olivier.

Olivier
Olivier

Merci Jo pour le remplacer/par, j'ai mis l'article à jour.

Jo
Jo

Arf les joies du copier coller qui marche pas !

Remplacer : echo '<pre>'.print_r($true, true).'</pre>';
Par : echo '<pre>' . print_r($tree, true) . '</pre>';

Sinon parfait ! C'est ce que je cherchais pour créer des minis Dom HTML ….

jbdemonte
jbdemonte

Super cette classe pour faire des html leger, merci.

une petite modif que je me suis rajouté pour ne pas avoir des node fils de <br> :

<?php

public function parse($html$namespace=NULL$encoding='utf-8')
    {
        $this->malformed = false;
        $this->encoding = $encoding;
        
        $html = preg_replace('#\<br((\s+)?)((/+)?)\>#i''<br />'$html);
bohwaz
bohwaz

Merci ça m'a bien servi pour un parseur html de ma conception, garbage2xhtml, qui est bien plus efficace comme ça :) garbage2xhtml

Olivier
Olivier

Pas de quoi, en plus j'aime beaucoup le titre de ta classe « garbage2xhtml » :-)

Ta classe a l'air drôlement intéressante d'ailleurs, j'y jetterai un œil ce weekend.

DimitriGilbert
DimitriGilbert

désolé, mais sinon y'a ca en natif :) : http://www.php.net/manual/fr/domdocument.loadhtmlfile.php et ensuite tu manipule comme du xml

Gilles
Gilles

Pas encore essayé, mais PHPsimpleHTMLDOMparser m'a l'air impressionnant et désarmant de facilité.

Olivier
Olivier

Est-ce qu'il est possible avec SHTMLDOMP de ne récupérer que certains éléments et que le reste soit traité comme du texte simple ? Les gabarits de mon CMS utilisent l'espace de nom wdp pour les fonctions de contrôle, alors lorsque je construis l'arbre du document je ne récupère que les balises wdp: et les reste en chaînes de caractères toutes bêtes.

J'ai crée le parser pour cette raison, et même si je préférerai utiliser une fonction PHP je n'ai pas très envie de me retrouver avec un millier d'objets qu'il faudra reconvertir en chaînes de caractères.

Gilles
Gilles

J'ai un peu plus d'expérience de SimpleHTMLDOMparser, maintenant, et c'est un vrai régal pour un tout petit programmeur comme moi. Je précise que je ne l'utilise que pour lire et détecter des balises et données, dans un fichier HTML distant (ex : $tag = $html->find('[rel=next]', 0); // cherche le 1er tag qui a un attribut rel="next").

Je n'ai pas tout compris dans ta question, mais avec cette library, il n'est plus besoin de gérer soi-même l'arbre récursif du DOM. Tu te balades dans l'arbre HTML, avec des méthodes au formalisme très lisible, lis ou modifies ce que tu veux et ne récupères que les objets ou attributs qui t'importent (et les erreurs de balises sont admises).

<?php

$html = file_get_html('http://www.google.com/');// Create a DOM object from a URL

$e1 = $html->find('table.hello td');// Find all «td» in «table» which class=hello (then : each() )

$id = $html->find("#div1"0)->children(1)->children(1)->children(2)->id;

$e1->outertext = '';// Remove a element = set it's outertext as an empty string 

$e->outertext = '«div»foo«div»' . $e->outertext;// Insert a element
 
$str = $html;// Dumps the internal DOM tree back into string

$html->clear()unset($html);// a bit heavy in memory

Donc, si tu ne veux traiter qu'une partie, soit tu lui donnes le code ($html = str_get_html('«html»«body»Hello!«/body»«/html»');), soit tu traites l'ensemble de la page et supprimes du DOM les parties inutiles puis sauves le résultat. C'est tout simple !

Adilis
Adilis

Salut, Tres sympa cette classe, elle va me servir sur un projet. Un petit bémol, si le texte final d'un noeud HTML est 0, il n'est pas pris en compte.

UrielMyeline
UrielMyeline

Merci beaucoup pour ce partage, qui répond parfaitement à bon besoin, j'ai essayer de m'amuser avec les expressions régulières mais pas encore assez doué pour y arriver. (Merci aussi à jbdemonte pour les br :p) Léger, très facile à lire/modifier, vraiment parfait :)

Sinon j'ai rencontré un petit hic, les propriétés doivent être entouré de ", sinon ils sont ignorés… J'ai rajouter ce petit bout de code vite fait, mais bon peu être qu'une meilleur solution est possible :

<?php

preg_match_all("#\s+([^=]+)\s*=\s*'([^']+)'#"$markup$matches_quote, PREG_SET_ORDER);
preg_match_all('#\s+([^=]+)\s*=\s*"([^"]+)"#'$markup$matches_doublequote, PREG_SET_ORDER);
$matches = array_merge($matches_quote$matches_doublequote);

Voila encore merci :D

Watt Communication
Watt Communication

Parser HTML très simple et très pratique sur un serveur PHP4. PHP5 intègre maintenant sont propre parser. (Cf : DOMDocument::loadHTMLFile.)

laurent
laurent

enfin un parser rapide, facile à utiliser (et relativement fiable) pour analyser vite fait un code html. rien à voir avec les autres usines à gaz

attention par contre:
- dans les cases, pas besoin de { }.. – et pour les arguments pas oublier les ' en plus des « 

preg_match_all('#\s+([^=]+)\s*=\s*["\']([^"\']+)["\']#', $markup, $matches, PREG_SET_ORDER);
laurent
laurent
  • PHPsimpleHTMLDOMparser : hyper lent. surdimensionné (du « jquery dans le php »: assez rare d'en avoir besoin)

  • http://www.php.net/manual/fr/domdocument.loadhtmlfile.php trop chiant à utiliser et resiste mal à du html pas bien formé

  • garbage2xhtml autant garder la philosophie de WdHTMLParser (simple, rapide) quitte à le faire légèrement evoluer (ex: faudrait juste gerer les < > " ' dans les attributs genre truc tordu: onclick="document.write('<..>')" )

perso, je reste sur cette classe facile à modifier