PHPスクレイピング用セレクターライブラリの実装

概要

この記事では、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フレームワーク内での使用にも適しています。

タグ: PHP スクレイピング dom XPath セレクター

6月16日 21:54 投稿