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 požadovanou 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ě.

Flattr this!

This entry was posted in PHP, Web. Bookmark the permalink.

4 Responses to PHP DOM, SimpleXML a Matcher

  1. Knyttl says:

    A zda-li pak Ti to funguje na PHP 5.4? Mám napsaný něco podobnýho, ale kvuli tomuhle mi to tam nefunguje: https://bugs.php.net/bug.php?…

    • Funguje, protože Matcher ve výchozím stavu používá DOM, který se chová kokrektně. Na SimpleXML se dá přepnout, pokud je to to, po čem tvé srdce touží.

  2. Knyttl says:

    Jak to čtu pořádně, tak ten problém, co popisuješ s SimpleXML, tak to je přesně problém s PHP 5.4.

  3. Honza Trtik says:

    Pro podobne ucely se da docela dobre pouzit YQL od yahoo

Leave a Reply

Your email address will not be published. Required fields are marked *