PHP reguláris kifejezések - Link attribútumainak megkeresése

A reguláris kifejezésekről nagyon sokat lehetne írni. Bevezethetném az elejétől, ahogy egyszer terveztem is, most mégis maradnék csak egy példa bemutatásánál, ami megkeresi egy html kódban az összes linket és azok összes attribútumát. Azért jó ez a példa, mert több eszköz tárgyalására is lehetőséget ad.

A preg_match függvényt fogom használni. Még lehetne az ereg függvényeket, de azokat ma már nem ajánlott.

Legyen a példa html kód a következő:

$text=<<<T
<a id='valami1' class="osztaly" href="http://url.hu" onclick="alert('teszt');">Szöveg</a>
<a id='id'></a>
<a id='valami2' href='http://url2.hu' style='color: red;' >Őzike</a>
<a href="url">Link</a>
T
;

A legegyszerűbb kifejezés, amiből ki lehet indulni ismerve a célt, a következő:

preg_match("'<a.*>.*</a>'",$text,$matches);

Azok kedvéért, akik preg_match-ot nem nagyon használtak még, elmondom, hogy a mintát egy tetszőlegesen választott határoló karakterpár közé kell tenni (delimiter), ahol a két karakter azonos. Itt most az aposztrófot választottam.

Hogy lássuk is az eredményt, a következő sort még be lehet írni alá:

print '<pre>'.htmlspecialchars(print_r($matches,true)).'</pre>';

Na most ez a kifejezés látszólag tökéletesen működik. Megkeres egy linket a $text változó tartalmában és beteszi a $matches nevű változóba az eredményt. De mi van, ha én egy sorban nem csak egy linket teszek a forrásba? Azért erre igen nagy az esély, valljuk be.
Nos, a válasz: A sor első linkjének kezdetétől az utolsó végéig fog tartani a visszaadott eredmény. Nem ez a cél. Ráadásul ha valaki csúnya módon nagybetűvel írja a html tag-ek neveit, akkor megint nem fog illeszkedni a minta. És ha megtöröm a linket valahol? Tegyük fel, entert ütök az id és az osztály deklaráció közé. Akkor sem találja meg azt a linket. Még jó, hogy vannak módosítóink. Ezeket a mintán belül, vagy a záró delimiter után is megadhatjuk. Most utóbbit fogom tenni.

preg_match("'<a.*>.*</a>'Uis",$text,$matches);

  • U: (Ungreedy) "mohóság" kikapcsolása. Vagyis a legelső zárja a mintát és nem az utolsó.

  • i: (insensitive) A minta nem lesz érzékeny a kis- és nagybetűk különbségére.

  • s: (Aki tudja, mondja meg) A csillag karakter a sortörésekre is illeszkedni fog.

Ez nagyszerű, de még mindig csak egy linket kapunk eredményül. Ezen segít, ha a preg_match helyett a preg_match_all() függvényt használjuk.

preg_match_all("'<a.*>.*</a>'Uis",$text,$matches);
Eredmény:

Array
(
    [0] => Array
        (
            [0] => <a id='valami1' class="osztaly" href="http://url.hu" onclick="alert('teszt');">Szöveg</a>
            [1] => <a id='id'></a>
            [2] => <a id='valami2' href='http://url2.hu' style='color: red;' >Őzike</a>
            [3] => <a href="url">Link</a>
        )

)

Zseniális! Siker! Ez maga a mámor. Csak tovább kéne lépni, mert ugye a linkek megvannak, de az attribútumok még nincsenek kiszedve külön. Ha nagyon akarnám, írhatnék egy ciklust, és egyenként megkeresném mindegyik linkben az attribútumokat. Most nem ezt teszem. Inkább folytatom a reguláris kifejezést. Meg kell mondani azt is a mintában, hogy hogyan néz ki egy attribútum. Mi sem egyszerűbb ennél. Név=érték és kész van. Ugye? Még mit nem! Az volna a szép, ha mindenki ugyanúgy írná a linkeket, de nem így van. Idézőjelet sem tesz ki mindenki. Lehet szóközt, entert ütni az egyenlőség jel előtt és után is. És vajon azt hogy mondjuk meg, hogy ha ' jellel kezdtem, akkor azzal is fejezzem be? Vagy ha " jellel, akkor értelem szerűen az legyen a végén is. Mindenre megvan a megoldás. Csak haladjunk sorjában.

Egy attribútum tehát kezdődik valamilyen A-tól Z-ig karakterekből álló, legalább 1 karakteres névvel. ( [a-z]+ ) Utána vagy ütöttem szóközöket, entereket, tabulátorokat, vagy nem. ( \s* ) Kell egy egyenlőség jel és utána ismét opcionális whitespace karakterek. ( \s* ) és .... Itt most feltételezzük egyelőre, hogy csak " jeleket használunk. Így egyszerűen csak a következő minta jön: ".*". Ja és persze ez után is opcionális whitespace karakterek. Azt viszont nem elfelejteni, hogy az nyitó tag "a" -ja után egy whitespace karakter mindenképp van. Tehát így néz ki az új reguláris kifejezés:

preg_match_all("'<a\s+\s*[a-z]+\s*=\s*\".*\"\s*>.*</a>'Uis",$text,$matches);

Egyre szebb lesz. És még csak 1 attribútum van. Eredményként pedig csak az utolsó link. Így ez kissé visszafejlődés az előzőhöz képest. Az attribútumok pedig külön kellenek kigyűjtve. Ehhez zárójelbe kell tenni az attribútum mintáját. És mint egy karakternél, vagy karakterosztálynál, itt is * karakterrel megmondjuk, hogy lehet akárhány attribútum.

preg_match_all("'<a\s+(\s*[a-z]+\s*=\s*\".*\"\s*)*>.*</a>'Uis",$text,$matches);

Na most az utolsó linket leszámítva az összes megbukik a vizsgán már az elején, mivel aposztrófot használnak. Pedig csak a " jelet tettük a mintába. Muszáj lesz ehhez is karakterosztályt használunk. Ami tartalmazza a szimpla és dupla idézőjelet is. És ezért is jó, hogy delimiternek aposztrófot használtam, mert nem kell gondolkodni, hogy melyik idézőjelet kell escapelni. Mindkettőt kell. Egyiket a string miatt, ami idézőjelben van, másikat a delimiter miatt.

preg_match_all("'<a\s+(\s*[a-z]+\s*=\s*[\'\"].*[\'\"]\s*)*>.*</a>'Uis",$text,$matches);

Majdnem jó, csak az a baj, hogy arra is illeszkedik, ha keverem a két idézőjelet. Egyikkel nyitok, másikkal zárok. Létezik egy olyan minta, amivel lehet hivatkozni egy másik almintára. Ezzel megmondva, hogy pontosan ugyanarra kell illeszkedni, mint első előfordulásánál. A következő lehetőségek vannak:

  • Megszámoljuk, hogy a zárójelbe tett alminta hányadik a sorban és

    • \n vagy \g{n} vagy \gn ahol n az alminta száma

    • (\g{-n}) ahol n relatív pozíció. Azaz megmondhatom, hogy a kettővel ezelőtti alminta.

  • Nevet adunk az almintának. (?P<nev>minta) és névvel hivatkozunk rá: \k<nev>

Utóbbit választom, mert kettőig már nehéz elszámolnom. Más részt sokkal átláthatóbb, mikor, mire hivatkozom. Ha pedig átírom a mintát, nem kell átírnom a hivatkozásokat. Csak ha a neveket is átírtam.

preg_match_all("'<a\s+(\s*[a-z]+\s*=\s*(?P<q>[\'\"]).*\k<q>\s*)*>.*</a>'Uis",$text,$matches);

Hogy még jobban összezavarjak mindenkit, közlöm, hogy kifelejtettük azt az eshetőséget, amikor a fránya programozó kihagyta az idézőjeleket teljesen. Tehát azt is meg kell adni, hogy whitespace karakterek is határolhassák az értéket. De ezt nem tehetjük a karakterosztályba, mert whitespace-ből több is lehet, idézőjelből egyszerre csak egy. Marad a "vagy" ( | ) operátor.

preg_match_all("'<a\s+(\s*[a-z]+\s*=\s*(?P<q>[\'\"]|\s*).*\k<q>\s*)*>.*</a>'Uis",$text,$matches);

Nem tudom, ki, hogy gondolja, de szerintem már nagyon eldurvult a minta. És még mindig nem vagyunk készen. Egyelőre még mindig csak egy attribútumot kapunk vissza. És az is a legutolsó mindig. Ha csak nem írunk mégis ciklust újabb preg_match-okkal, én nem tudok jobb megoldást, mint több almintát felsorolni. És még a nevüket is megadom, hogy tudjak hivatkozni arra is, hogy id vagy href, vagy style, vagy akármilyen tulajdonságról is van szó. Ember legyen a talpán, aki még mindig tudja követni és szívesen folytatná a minta írást ebben a formában. Így kis segítséghez nyúlok. Ez pedig egy függvény lesz. Írok tehát egy függvényt, ami visszaadja egy megadott nevű attribútum mintakódját.

Ez a következőképp néz ki:

function attrPt($attr, $quotename, $valuename=null)
{
        $value = ".*";
        if (!is_null($valuename))
        {
                $value = "(?P<$valuename>$value)";
        }
        return "($attr\s*=\s*(?P<$quotename>[\'\"]|\s*)$value\k<$quotename>\s*)";
}

A függvény 3 paramétert vár. Ebből az utolsó opcionális. Akkor kell megadni, ha nevet akarunk adni az attribútum értékének. Az első az attribútum neve maga. Praktikus ezzel megjelölni majd az értéket is. De erről majd később.
A második paraméter az idézőjel neve lesz. Minden attribútumnál más, mivel normál esetben nem egyezhet meg két minta neve.
Most már ezt felhasználva az előző minta így néz ki:

$attr = attrPt('[a-z]+','q1');
preg_match_all("'<a\s+$attr*>.*</a>'Uis",$text,$matches);

Valamivel szebb, de nekem több attribútum is kell. Egy egyszerű "vagy" operátor ( | ) kell hozzá. Tehát keresünk vagy href-eket, vagy bármi mást.

$attr = attrPt('[a-z]+','q');
$href = attrPt('href','qhref','href');
preg_match_all("'<a\s+($href|$attr)*>.*</a>'Uis",$text,$matches);

Most már muszáj megemlítenem azt is, hogy gyorsvonat sebességre nem kell számítani ilyen kifejezéseknél, de a lehetőség adott a használatukra.

Ennél azért több kell. Tehát most hamar írok egy függvényt arra is, hogy megadhassam azon attribútumok neveit, amik érdekelnek.

function attrListPt($list)
{
        $ret = "";
        foreach ($list as $item)
        {
                $ret .= attrPt($item,'q'.$item, $item).'|';
        }
        $ret .= attrPt('[a-z]+','q');
        return '('.$ret.')*';
}

Ez a függvény egy tömböt vár. Amiben felsoroljuk a kért attribútumokat.

$list = attrListPt(array('href','id','style','class','onclick'));
preg_match_all("'<a\s+$list>.*</a>'Uis",$text,$matches);

Eredménye egy ku... tya nagy tömb, aminek ráadásul sok eleme teljesen üres. Viszont az első link url-je kiírható a

print $matches['href'][0];

kóddal.

Még lehet takarítani az eredményt. Ugyanis bizonyos almintákról biztosan tudjuk, hogy nem kell. Ilyenkor a minta elejére a kérdőjel, kettőspont karaktereket kell írni. Ezzel a mintaillesztésben még szerepet játszanak, de nem lesznek kiemelve a $matches tömbbe. Bár ebben a mintában talán csak egyet lehet eltüntetni. Illetve kis átalakítással megspórolható lenne az idézőjelek elnevezett verziója. Ezt most nem oldom már meg, de szerintem már így is félőrültnek tartanak a kedves olvasók, hogy ennyit szórakozom a reguláris kifejezésekkel, amikor másképp is megoldható volna. És talán gyorsabban is.

Például van egy beépített osztály php-ben. Ez a DomDocument. Nagyon hasonlóan működik, mint a javascriptből megszokott dom manipuláló metódusok. A használata logikus és kényelmes. Ha viszont egy hiba is van a html-ben, megbukik az eljárás parseolás közben. És nem csak a hibás részt hagyja ki az eredményből. De azért nézzük, hogy is nézne ki az eddigi hosszan ecsetelt eljárás DomDocument-tel.

$doc = new DomDocument();
$doc->loadHtml($text);

$anchors = $doc->getElementsByTagName('a');

$matches2 = array();
$attributes = array('href','id','style','class','onclick');
foreach ($anchors as $key => $anchor)
{
        foreach($attributes as &$attrname)
        {
                $matches2[$attrname][$key] = $anchor->getAttribute($attrname);
        }
}

Na de még az elején említettem, hogy írhattam volna ciklust is és több preg_match_all -al kereshettem volna meg az attribútumokat. Nosza, jöjjön ez a megoldás is.

preg_match_all("'<a .*>.*</a>'Uis",$text, $matches3_1);
$matches3 = array();
foreach ($matches3_1[0] as $key=>&$v)
{
        preg_match_all("'([a-z]+)\s*=\s*([\'\"]|\s*)(?P<value>.*)\g{-2}'Uis",$v, $matches3_2);
        foreach($matches3_2[1] as $key2=>&$name)
        {
                $matches3[$matches3_2[1][$key2]][$key] = $matches3_2[3][$key2];
        }
}

Hosszabb ez se lett, mint az elődjeik. Rövidebb kifejezéseket kellett írni, ráadásul fel se kellett sorolni a neveket. Minden attribútumot megkaptunk eredményül a $matches3 tömbben és nincs feleslegesen tárolt elem.

Most akkor végezzünk a 3 megoldással egy sebesség tesztet. Erre a következő rövid osztályt használom:

class Test
{
        private static $time = array();
       
        public static function addTime()
        {
                self::$time[] = microtime(true);
        }

        public static function getResults()
        {
                print "<hr />Eredmény: <br />";
                $c = count(self::$time);
                for ($i=1; $i<$c; $i++)
                {
                        print "<b>".$i.":</b> ".((self::$time[$i]-self::$time[$i-1]) * 1000)."<br />";
                }
                print "<hr />";
        }
}

Az addTime() metódussal mindig megjelölök egy pontot, ahol az időt mérni akarom. És a getResults() pedig kiírja a referencia pontok közötti időkülönbséget. Ezzel megtudom, melyik eljárás volt a gyorsabb- Az eredmény nanoszekundumban érkezik. Tehát a különbség kicsi, de ne becsüljük le a hatását egy nagy programban, akár egy nagy ciklusban.

<html>
<head>
<meta http-equiv="content-type" content="text/html; charset=utf-8" />
<body>
<?php
class Test
{
        private static $time = array();
       
        public static function addTime()
        {
                self::$time[] = microtime(true);
        }

        public static function getResults()
        {
                print "<hr />Eredmény: <br />";
                $c = count(self::$time);
                for ($i=1; $i<$c; $i++)
                {
                        print "<b>".$i.":</b> ".((self::$time[$i]-self::$time[$i-1]) * 1000)."<br />";
                }
                print "<hr />";
        }
}

$text = <<<T
<a id='valami1' class="osztaly" href="http://url.hu" onclick="alert('teszt');">Szöveg</a>
<a id='id'></a>
<a id='valami2' href='http://url2.hu' style='color: red;' >Őzike</a>
T
;
function attrPt($attr, $quotename, $valuename=null)
{
        $value = ".*";
        if (!is_null($valuename))
        {
                $value = "(?P<$valuename>$value)";
        }
        return "($attr\s*=\s*(?P<$quotename>[\'\"]|\s*)$value\k<$quotename>\s*)";
}
function attrListPt($list)
{
        $ret = "";
        foreach ($list as $item)
        {
                $ret .= attrPt($item,'q'.$item, $item).'|';
        }
        $ret .= attrPt('[a-z]+','q');
        return '('.$ret.')*';
}
Test::addTime();
$list = attrListPt(array('href','id','style','class','onclick'));
preg_match_all("'<a\s+$list>.*</a>'Uis",$text,$matches1);

Test::addTime();

//DomDocument-tel
$doc = new DomDocument();
$doc->loadHtml($text);

$anchors = $doc->getElementsByTagName('a');

$matches2 = array();
$attributes = array('href','id','style','class','onclick');
foreach ($anchors as $key => $anchor)
{
        foreach($attributes as &$attrname)
        {
                $matches2[$attrname][$key] = $anchor->getAttribute($attrname);
        }
}

Test::addTime();

//regexp+ciklus
preg_match_all("'<a .*>.*</a>'Uis",$text, $matches3_1);
$matches3 = array();
foreach ($matches3_1[0] as $key=>&$v)
{
        preg_match_all("'([a-z]+)\s*=\s*([\'\"]|\s*)(?P<value>.*)\g{-2}'Uis",$v, $matches3_2);
        foreach($matches3_2[1] as $key2=>&$name)
        {
                $matches3[$matches3_2[1][$key2]][$key] = $matches3_2[3][$key2];
        }
}

Test::addTime();
Test::getResults();

print '<pre>'.htmlspecialchars(print_r($matches1,true)).'</pre>';
print '<pre>'.htmlspecialchars(print_r($matches2,true)).'</pre>';
print '<pre>'.htmlspecialchars(print_r($matches3,true)).'</pre>';
?>
</body>
</html>

Nálam a következő eredmény jött ki:
Eredmény:

1: 0.117063522339
2: 0.135898590088
3: 0.0810623168945

Látható, hogy a DomDocument, bár beépített, a leglassabb volt. A nagyon hosszú, komoly reguláris kifejezésünk verte a DomDocument-et, de az abszolút győztes mégis a több reguláris kifejezés ciklusban való alkalmazása volt.

A tanulság. A reguláris kifejezés jó! Azonban figyelembe kell venni, vannak-e alternatív módszerek. Beépített megoldások. De ezek sem feltétlen lesznek gyorsabbak, vagy jobbak.

Végezetül az első megoldás által generált reguláris kifejezés, hogy mindenkit megrémítsek:

'<a\s+((href\s*=\s*(?P<qhref>[\'"]|\s*)(?P<href>.*)\k<qhref>\s*)|(id\s*=\s*(?P<qid>[\'"]|\s*)(?P<id>.*)\k<qid>\s*)|(style\s*=\s*(?P<qstyle>[\'"]|\s*)(?P<style>.*)\k<qstyle>\s*)|(class\s*=\s*(?P<qclass>[\'"]|\s*)(?P<class>.*)\k<qclass>\s*)|(onclick\s*=\s*(?P<qonclick>[\'"]|\s*)(?P<onclick>.*)\k<qonclick>\s*)|([a-z]+\s*=\s*(?P<q>[\'"]|\s*).*\k<q>\s*))*>.*</a>'Uis

Ugye, hogy nem lett volna kellemes kézzel megírni?

Kategóriák: 
Megosztás/Mentés