概要
この記事では、PHPでスクレイピングを行うためのセレクターライブラリの実装について解説します。このライブラリは、phpspiderプロジェクトから抽出した独立したコンポーネントとして利用できます。
インストール
このセレクターライブラリは、Composerを介してインストールできます。ただし、ここではコア機能のみを抽出した実装を紹介します。
コード実装
<?php
namespace App\Scraping;
use DOMDocument;
use DOMXPath;
use Exception;
class HtmlSelector
{
/**
* バージョン情報
* @var string
*/
const VERSION = '1.0.2';
private static $dom = null;
private static $dom_auth = '';
private static $xpath = null;
private static $error = null;
/**
* HTMLから要素を選択する
*
* @param string $html 解析対象のHTML
* @param string $selector セレクター
* @param string $selector_type セレクタータイプ ('xpath', 'css', 'regex')
* @return mixed 選択結果
*/
public static function select($html, $selector, $selector_type = 'xpath')
{
if (empty($html) || empty($selector)) {
return false;
}
$selector_type = strtolower($selector_type);
switch ($selector_type) {
case 'xpath':
return self::_xpath_select($html, $selector);
case 'regex':
return self::_regex_select($html, $selector);
case 'css':
return self::_css_select($html, $selector);
default:
return false;
}
}
/**
* HTMLから指定要素を削除する
*
* @param string $html 処理対象のHTML
* @param string $selector 削除対象のセレクター
* @param string $selector_type セレクタータイプ
* @return string 処理後のHTML
*/
public static function remove($html, $selector, $selector_type = 'xpath')
{
if (empty($html) || empty($selector)) {
return false;
}
$remove_html = "";
$selector_type = strtolower($selector_type);
switch ($selector_type) {
case 'xpath':
$remove_html = self::_xpath_select($html, $selector, true);
break;
case 'regex':
$remove_html = self::_regex_select($html, $selector, true);
break;
case 'css':
$remove_html = self::_css_select($html, $selector, true);
break;
}
return str_replace($remove_html, "", $html);
}
/**
* XPathセレクターの実装
*
* @param string $html 解析対象HTML
* @param string $selector XPathセレクター
* @param bool $remove 削除モードかどうか
* @return mixed 選択結果
*/
private static function _xpath_select($html, $selector, $remove = false)
{
if (!self::$dom instanceof DOMDocument) {
self::$dom = new DOMDocument();
}
// HTMLが変更された場合のみDOMを再構築
if (self::$dom_auth !== md5($html)) {
self::$dom_auth = md5($html);
@self::$dom->loadHTML('<?xml encoding="UTF-8"?>' . $html);
self::$xpath = new DOMXPath(self::$dom);
}
$elements = @self::$xpath->query($selector);
if ($elements === false) {
self::$error = "XPathセレクター(\"{$selector}\")に構文エラーがあります";
return null;
}
$result = [];
if ($elements !== null) {
foreach ($elements as $element) {
if ($remove) {
$content = self::$dom->saveXml($element);
} else {
$nodeName = $element->nodeName;
$nodeType = $element->nodeType;
// 画像タグの場合はsrc属性を取得
if ($nodeType == 1 && in_array($nodeName, ['img', 'source'])) {
$content = $element->getAttribute('src');
}
// 属性またはテキストノードの場合
elseif ($nodeType == 2 || $nodeType == 3 || $nodeType == 4) {
$content = $element->nodeValue;
} else {
// XMLとして保存し、ラッパータグを除去
$content = self::$dom->saveXml($element);
$content = preg_replace(
["#^<{$nodeName}.*>#isU", "#</{$nodeName}>$#isU"],
['', ''],
$content
);
}
}
$result[] = $content;
}
}
return empty($result) ? null : (count($result) > 1 ? $result : $result[0]);
}
/**
* CSSセレクターの実装
*
* @param string $html 解析対象HTML
* @param string $selector CSSセレクター
* @param bool $remove 削除モードかどうか
* @return mixed 選択結果
*/
private static function _css_select($html, $selector, $remove = false)
{
$selector = self::css_to_xpath($selector);
return self::_xpath_select($html, $selector, $remove);
}
/**
* 正規表現セレクターの実装
*
* @param string $html 解析対象HTML
* @param string $selector 正規表現パターン
* @param bool $remove 削除モードかどうか
* @return mixed 選択結果
*/
private static function _regex_select($html, $selector, $remove = false)
{
if (@preg_match_all($selector, $html, $out) === false) {
self::$error = "正規表現セレクター(\"{$selector}\")に構文エラーがあります";
return null;
}
$count = count($out);
$result = [];
// マッチしなかった場合
if ($count == 0) {
return null;
}
// 一つのグループのみマッチした場合
elseif ($count == 2) {
$result = $remove ? $out[0] : $out[1];
} else {
for ($i = 1; $i < $count; $i++) {
$result[] = count($out[$i]) > 1 ? $out[$i] : $out[$i][0];
}
}
return empty($result) ? null : (count($result) > 1 ? $result : $result[0]);
}
/**
* CSSセレクターをXPathに変換
*
* @param string $selectors CSSセレクター
* @return string XPathセレクター
*/
public static function css_to_xpath($selectors)
{
$queries = self::parse_selector($selectors);
$delimiter_before = false;
$xquery = '';
foreach ($queries as $s) {
// タグ名
$is_tag = preg_match('@^[\w|\||-]+$@', $s) || $s == '*';
if ($is_tag) {
$xquery .= $s;
}
// IDセレクター
else if ($s[0] == '#') {
if ($delimiter_before) {
$xquery .= '*';
}
$xquery .= "[@id='" . substr($s, 1) . "']";
}
// クラスセレクター
else if ($s[0] == '.') {
if ($delimiter_before) {
$xquery .= '*';
}
$xquery .= "[contains(@class,'" . substr($s, 1) . "')]";
}
// 属性セレクター
else if ($s[0] == '[') {
if ($delimiter_before) {
$xquery .= '*';
}
$attr = trim($s, '][');
if (mb_strpos($s, '=')) {
$value = null;
list($attr, $value) = explode('=', $attr);
$value = trim($value, "'\"");
if (self::is_regexp($attr)) {
$attr = substr($attr, 0, -1);
$xquery .= "[@{$attr}]";
} else {
$xquery .= "[@{$attr}='{$value}']";
}
} else {
$xquery .= "[@{$attr}]";
}
}
// 子孫セレクター
else if ($s == '>') {
$xquery .= '/';
$delimiter_before = 2;
}
// 子孫セレクター(全て)
else if ($s == ' ') {
$xquery .= '//';
$delimiter_before = 2;
}
}
return $xquery;
}
/**
* CSSセレクターをパース
*
* @param string $query CSSセレクター文字列
* @return array パースされたセレクターコンポーネント
*/
public static function parse_selector($query)
{
$query = trim(preg_replace('@\s+@', ' ',
preg_replace('@\s*(>|\\+|~)\s*@', '\\1', $query)));
$queries = [];
if (!$query) {
return $queries;
}
$special_chars = ['>', ' '];
$strlen = mb_strlen($query);
$class_chars = ['.', '-'];
$tag_chars = ['*', '|', '-'];
// マルチバイト文字を配列に変換
$_query = [];
for ($i = 0; $i < $strlen; $i++) {
$_query[] = mb_substr($query, $i, 1);
}
$query = $_query;
$i = 0;
while ($i < $strlen) {
$c = $query[$i];
$tmp = '';
// タグ名
if (self::is_char($c) || in_array($c, $tag_chars)) {
while (isset($query[$i]) &&
(self::is_char($query[$i]) || in_array($query[$i], $tag_chars))) {
$tmp .= $query[$i];
$i++;
}
$queries[] = $tmp;
}
// IDセレクター
else if ($c == '#') {
$i++;
while (isset($query[$i]) &&
(self::is_char($query[$i]) || $query[$i] == '-')) {
$tmp .= $query[$i];
$i++;
}
$queries[] = '#' . $tmp;
}
// 特殊文字
else if (in_array($c, $special_chars)) {
$queries[] = $c;
$i++;
}
// クラスセレクター
else if ($c == '.') {
while (isset($query[$i]) &&
(self::is_char($query[$i]) || in_array($query[$i], $class_chars))) {
$tmp .= $query[$i];
$i++;
}
$queries[] = $tmp;
}
// 属性セレクター
else if ($c == '[') {
$stack = 1;
$tmp .= $c;
while (isset($query[++$i])) {
$tmp .= $query[$i];
if ($query[$i] == '[') {
$stack++;
} else if ($query[$i] == ']') {
$stack--;
if (!$stack) {
break;
}
}
}
$queries[] = $tmp;
$i++;
}
// 疑似クラス
else if ($c == ':') {
$stack = 1;
$tmp .= $query[$i++];
while (isset($query[$i]) &&
(self::is_char($query[$i]) || $query[$i] == '-')) {
$tmp .= $query[$i];
$i++;
}
// 引数がある場合
if (isset($query[$i]) && $query[$i] == '(') {
$tmp .= $query[$i];
$stack = 1;
while (isset($query[++$i])) {
$tmp .= $query[$i];
if ($query[$i] == '(') {
$stack++;
} else if ($query[$i] == ')') {
$stack--;
if (!$stack) {
break;
}
}
}
$queries[] = $tmp;
$i++;
} else {
$queries[] = $tmp;
}
} else {
$i++;
}
}
// クエリの前処理
if (isset($queries[0])) {
if (isset($queries[0][0]) && $queries[0][0] == ':') {
array_unshift($queries, '*');
}
if ($queries[0] != '>') {
array_unshift($queries, ' ');
}
}
return $queries;
}
/**
* 文字が英数字かチェック
*
* @param string $char チェック対象文字
* @return bool
*/
public static function is_char($char)
{
return preg_match('@\w@', $char);
}
/**
* 正規表現パターンか判定
*
* @param string $pattern パターン文字列
* @return bool
*/
protected static function is_regexp($pattern)
{
return in_array(
$pattern[mb_strlen($pattern) - 1],
['^', '*', '$']
);
}
}
使用例
以下に、GuzzleHttpと組み合わせてこのセレクターライブラリを使用する例を示します。
use GuzzleHttp\Client;
use App\Scraping\HtmlSelector;
class ScrapingService
{
/**
* @param int $page ページ番号
* @return array スクレイピング結果
*/
public static function getLotteryResults(int $page)
{
$client = new Client();
$response = $client->get('http://kaijiang.zhcw.com/zhcw/html/ssq/list_' . $page . '.html');
$html = (string)$response->getBody();
// テーブル要素を抽出
$tables = HtmlSelector::select($html, "table");
// 最初のテーブルからデータを抽出
if ($tables) {
$data = HtmlSelector::select($tables[0], "tr");
return $data;
}
return [];
}
}
この実装はPHP 7.1から8.0までのバージョンで動作確認済みです。Laravelフレームワーク内での使用にも適しています。