PHP DOM, SimpleXML a Matcher
Však to znáte: Tak dlouho chodíte se džbánem pro vodu, až si na to napíšete framework. Já jsem tak dlouho crawloval a robotoval, až jsem si na to napsal Matcher. I když k frameworku má velice daleko, protože jde jen o dvě třídy.
Pokud chci v PHP extrahovat data z HTML dokumentu, mám na výběr mezi dvěma zly: DOM a SimpleXML.
Kód, který používá DOM může vypadat nějak takto:
$dom = @ \DOMDocument::loadHTML($htmlString); $xpath = new \DOMXpath($dom); $nodes = $xpath->query('//div[@class="post"]'); $res = []; foreach ($nodes as $node) { $res[] = [ 'id' => $xpath->query('@id', $node)->item(0)->textContent, 'date' => $xpath->query('div[@class="date"]', $node)->item(0)->textContent, 'title' => $xpath->query('h2', $node)->item(0)->textContent, 'text' => $xpath->query('div[@class="body"]', $node)->item(0)->textContent, ]; }
Jde o víc psaní než by se mi líbilo. Navíc s každou úrovní zanoření požadovaných dat přibude jedna vnitřní smyčka.
SimpleXML se může zdát jako lepší volba, protože má na první pohled přehlednější API, ale to je jenom past na nepozorné. Tak předně SimpleXML nedokáže načíst HTML dokument a je nutno ho nejdřívě naparsovat DOMem a teprve potom importovat do SimpleXML.
$dom = @ \DOMDocument::loadHTML($htmlString); $xml = simplexml_import_dom($dom); $nodes = $xml->xpath('//div[@class="post"]'); $res = []; foreach ($nodes as $node) { $res[] = [ 'id' => (string) $node->xpath('@id')[0], 'date' => (string) $node->xpath('div[@class="date"]')[0], 'title' => (string) $node->xpath('h2')[0], 'text' => dom_import_simplexml($node->xpath('div[@class="body"]')[0])->textContent, ]; }
SimpleXML dále neumí extrahovat textové elementy, neumí získat textový obsah celého podstromu elementů a hlavně špatně vyhodnocuje XPath dotazy.
Pokud mám dokument, který vypadá takto:
<el> <crap>i dont' care about this</crap> this is really important <crap>this is crap</crap> </el>
za žádnou cenu nemůžu extrahovat text "this is really important". Ani přes API
ani přes XPath dotaz. A když chci získat celý textový obsah elementu <el>
včetně dětí <crap>
, musím <el>
importovat do DOMu a na něm zavolat
textContent
. Bohužel právě tohle jsou věci, které při crawlování HTML
dokumentů dělám velice často. Na jedné straně tedy mám DOM, který má barokní
API a na druhé straně SimpleXML, která má API velice jednoduché, ale
nepoužitelné.
Právě z těchto důvodů jsem si napsal Matcher, se kterým je parsování a extrakce dat z HTML až trapně jednoduchá.
V Matcheru by výše uvedený příklad vypadal krásně deklarativně:
$m = Matcher::multi('//div[@class="post"]', [ 'id' => '@id', 'date' => 'div[@class="date"]', 'title' => 'h2', 'text' => 'div[@class="body"]', ])->fromHtml(); $m($htmlString);
Výsledný Matcher je obyčejná funkce, která na vstupu vezme HTML řetězec a vrátí data, jejicž tvar odpovídá tomu, jak byl Matcher deklarován.
[ ['id' => '...', 'date' => '...', 'title' => '...', 'text' => '...'], ['id' => '...', 'date' => '...', 'title' => '...', 'text' => '...'], ['id' => '...', 'date' => '...', 'title' => '...', 'text' => '...'], ... ]
Pokud nechci pole polí, ale pole objektů. Stačí vzor přetypovat na objekt a výsledek bude mít požadovanou strukturu.
$m = Matcher::multi('//div[@class="post"]', (object) [ 'id' => '@id', 'date' => 'div[@class="date"]', 'title' => 'h2', 'text' => 'div[@class="body"]', ])->fromHtml();
Pokud chci extrahovat vnořená data, nemusím psát vnitřní smyčky, ale jenom do sebe vnořím Matchery. Kód je stále krásně deklarativní a výsledná data mají stále strukturu odpovídající vzoru.
$m = Matcher::multi('//div[@class="post"]', [ 'id' => '@id', 'date' => 'div[@class="date"]', 'title' => 'h2', 'text' => 'div[@class="body"]', 'tags' => Matcher::multi('.//div[@class="tag"]'), 'comments' => Matcher::multi('.//div[@class="comments"]', [ 'name' => 'div[@class="name"]' 'text' => 'div[@class="text"]' ]), ])->fromHtml();
Výsledek:
[ [ 'id' => '...', 'date' => '...', 'title' => '...', 'text' => '...', 'tags' => ['...', '...'], 'comments' => [[ 'name' => '...', 'text' => '...' ], [ ... ]], ], ... ]
Když je potřeba nalezené řetězce nějak upravit, můžu použít Matcher single
,
který nevrátí pole, ale jen první nalezený element a následně metodu map
,
která transformuje výsledek Matcheru nějakou funkcí.
$m = Matcher::multi('//div[@class="post"]', [ 'id' => Matcher::single('@id')->asInt(), 'date' => Matcher::single('div[@class="date"]')->map('strtotime'), 'title' => 'h2', 'text' => 'div[@class="body"]', 'tags' => Matcher::multi('.//div[@class="tag"]'), 'meta' => function ($node) { return doSomethingWithDomNode($node); } ])->fromHtml();
K dispozici jsou ještě metody: asInt
, asFloat
, first
(matcher vrátí jenom
první výsledek z kolekce) nebo regex
(na výsledek matcheru aplikuje regulérní
výraz a to i rekurzivně).
Ve výsledku může třeba takhle vypadat crawler, který používá Matcher, Atrox\Curl a Atrox\Async a je schopný crawlovat paralelně.