funkcionálně.cz

Přední český blog o funkcionálním programování, kde se o funkcionálním programování nepíše
««« »»»

PHP DOM, SimpleXML a Matcher

15. 5. 2014 — k47

Však to znáte: Tak dlouho cho­díte se džbá­nem pro vodu, až si na to na­pí­šete fra­mework. Já jsem tak dlouho crawlo­val a ro­bo­to­val, až jsem si na to napsal Matcher. I když k fra­meworku má velice daleko, pro­tože jde jen o dvě třídy.

Pokud chci v PHP ex­tra­ho­vat data z HTML do­ku­mentu, mám na výběr mezi dvěma zly: DOMSim­pleXML.

Kód, který po­u­žívá DOM může vy­pa­dat 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í za­no­ření po­ža­do­va­ných dat při­bude jedna vnitřní smyčka.

Sim­pleXML se může zdát jako lepší volba, pro­tože má na první pohled pře­hled­nější API, ale to je jenom past na ne­po­zorné. Tak předně Sim­pleXML ne­do­káže načíst HTML do­ku­ment a je nutno ho nejdřívě na­par­so­vat DOMem a teprve potom im­por­to­vat do Sim­pleXML.

$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,
  ];
}

Sim­pleXML dále neumí ex­tra­ho­vat tex­tové ele­menty, neumí získat tex­tový obsah celého pod­stromu ele­mentů a hlavně špatně vy­hod­no­cuje XPath dotazy.

Pokud mám do­ku­ment, 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 ex­tra­ho­vat text „this is really im­por­tant“. Ani přes API ani přes XPath dotaz. A když chci získat celý tex­tový obsah ele­mentu <el> včetně dětí <crap>, musím <el> im­por­to­vat do DOMu a na něm za­vo­lat textContent. Bo­hu­žel právě tohle jsou věci, které při crawlo­vání HTML do­ku­mentů dělám velice často. Na jedné straně tedy mám DOM, který má ba­rokní API a na druhé straně Sim­pleXML, která má API velice jed­no­du­ché, ale ne­po­u­ži­telné.

Právě z těchto důvodů jsem si napsal Matcher, se kterým je par­so­vání a ex­trakce dat z HTML až trapně jed­no­du­chá.

V Matcheru by výše uve­dený pří­klad vy­pa­dal krásně de­kla­ra­tivně:

$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 ře­tě­zec a vrátí data, jejicž tvar od­po­vídá tomu, jak byl Matcher de­kla­ro­ván.

[
  ['id' => '...', 'date' => '...', 'title' => '...', 'text' => '...'],
  ['id' => '...', 'date' => '...', 'title' => '...', 'text' => '...'],
  ['id' => '...', 'date' => '...', 'title' => '...', 'text' => '...'],
  ...
]

Pokud nechci pole polí, ale pole ob­jektů. Stačí vzor pře­ty­po­vat na objekt a vý­sle­dek bude mít po­ža­do­va­nou struk­turu.

$m = Matcher::multi('//div[@class="post"]', (object) [
  'id'    => '@id',
  'date'  => 'div[@class="date"]',
  'title' => 'h2',
  'text'  => 'div[@class="body"]',
])->fromHtml();

Pokud chci ex­tra­ho­vat vno­řená data, ne­mu­sím psát vnitřní smyčky, ale jenom do sebe vnořím Matchery. Kód je stále krásně de­kla­ra­tivní a vý­sledná data mají stále struk­turu od­po­ví­da­jí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ý­sle­dek:

[
  [
    'id' => '...',
    'date' => '...',
    'title' => '...',
    'text' => '...',
    'tags' => ['...', '...'],
    'comments' => [[
      'name' => '...',
      'text' => '...'
    ], [
      ...
    ]],
  ],
  ...
]

Když je po­třeba na­le­zené ře­tězce nějak upra­vit, můžu použít Matcher single, který ne­vrátí pole, ale jen první na­le­zený ele­ment a ná­sledně metodu map, která trans­for­muje vý­sle­dek Matcheru ně­ja­kou 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 dis­po­zici jsou ještě metody: asInt, asFloat, first (matcher vrátí jenom první vý­sle­dek z ko­lekce) nebo regex (na vý­sle­dek matcheru apli­kuje re­gu­lérní výraz a to i re­kur­zivně).

Ve vý­sledku může třeba takhle vy­pa­dat crawler, který po­u­žívá Matcher, Atrox\CurlAtrox\Async a je schopný crawlo­vat pa­ra­lelně.

@kaja47, kaja47@k47.cz, deadbeef.k47.cz, starší články