行き着く先はあんこ

PHPでスクレイピングを試みる【タイトル、サムネイル、本文取得】

PHPでWebスクレイピングするという内容の記事です。データを取得したいページのURLを指定すると、ページタイトルやサムネイル、本文をスクレイピングしてくれるものを目標として進めます。

スポンサーリンク

PHPでスクレイピング

今回はPHPを用いてスクレイピングをします。そのためJavaScriptが動作しない環境下(動作前)のHTMLのデータのみ取得することができます。また、XPathを指定して任意の箇所のデータを取得するのではなく、URLを入力すると、タイトルや本文が取得できるといったものになります。

HTMLを取得する

始めにスクレイピング対象のHTMLをデータとして取得します。今回はPHPのcURL関数を使います。

function curl_get_contents($url='') {
    if ($url=='') return false;

    $ch = curl_init();
    curl_setopt_array($ch, array(
        CURLOPT_URL => $url, //スクレイピング対象のurl
        CURLOPT_REFERER => $url, //リファラー
        CURLOPT_SSL_VERIFYPEER => false, // curlはサーバー認証をしない
        CURLOPT_RETURNTRANSFER => true, // 文字列で返す
        CURLOPT_USERAGENT => "Mozilla/5.0 (Windows; U; Windows NT 6.1; en-US) AppleWebKit/533.4 (KHTML, like Gecko) Chrome/5.0.375.125 Safari/533.4", //ユーザーエージェント
    ));
    $html = curl_exec($ch);
    // エラー処理
    if (curl_errno($ch)) {
        echo 'Curl error: '.curl_errno($ch);
        curl_close($ch);
        return false;
    }
    curl_close($ch);

    // 文字コードをUTF-8に変換
    $html = mb_convert_encoding($html, 'UTF-8', 'ASCII,JIS,UTF-8,EUC-JP,SJIS');

    // preタグを避難
    preg_match_all('/<pre[^>]*?>[\s\S]*?<\/pre>/', $html, $pre_codes);
    if($pre_codes[0][0] !== null) {
        $i = 1;
        foreach($pre_codes[0] as $pre) {
            $html = str_replace($pre, '__REPLACE_PRE_'.$i.'__', $html);
            $i++;
        }
    }
    // 改行をなくす
    $html = str_replace(array("\r\n", "\r", "\n"), ' ', $html);
    // preタグを戻す
    if($pre_codes[0][0] !== null) {
        $i = 1;
        foreach($pre_codes[0] as $pre) {
            $html = str_replace('__REPLACE_PRE_'.$i.'__', $pre, $html);
            $i++;
        }
    }

    // コメント、スクリプト、svgを削除
    $html = preg_replace(array(
        '/<!--[\s\S]*?-->/',
        '/<script[^>]*?>.*?<\/script>/i',
        '/<noscript[^>]*?>.*?<\/noscript>/i',
        '/<time[^>]*?>.*?<\/time>/i',
        '/<svg[^>]*?>.*?<\/svg>/i',
    ), '', $html);

    return $html;
}

curl_get_contents()にURLを渡すとHTMLの文字列が取得できます。のちのち作業しやすいように、文字コードをUTF-8に変換して改行もなくしておきます。ユーザーエージェントはなんでもいいのですが指定なしではデータが取得できない場合もあるので、適当に指定します。その他コメントやsvg、script要素などは今回使わないので削除しておきます。

HTMLからデータを取得する

HTMLが取得できたので、これからタイトル、サムネイル、本文を取得していきます。

タイトル

ページタイトルはhead内のtitleタグを参照します。


function get_article_title($html) {
    $title = '';
    preg_match('/<title>(.*?)<\/title>/iu', $html, $match_title);
    $title = strip_tags($match_title[1]);

    //「 | 」「 - 」「:」を除去
    while (preg_match('/ [\|\-\:] /', $title)) {
        $title = preg_replace('/(.+) [\|\-\:] .+/', '$1', $title);
    }

    $title = trim($title);

    $percent = 60;
    preg_match_all('/<h[1-3][^>]*?>(.*?)<\/h[1-3]>/i', $html, $match_titles);
    foreach($match_titles[1] as $match_title) {
        $match_title = trim(strip_tags($match_title));
        similar_text($title, $match_title, $per);
        if ($per > $percent) {
            $title = $match_title;
            break;
        }
    }

    return $title;
}

はじめにtitleタグを探し、titleタグ内のテキストをタイトル候補とします。次に、「|」「-」「:」などが含まれていれば、該当箇所以降を削除したテキストをタイトル候補とします。

さいごにh1からh3タグ内のテキストを取得して、タイトル候補のテキストと比較します。ある程度類似していればhタグ内のテキストを、類似していなければタイトル候補のテキストをページタイトルとします。

この方法で取得できるページタイトルは「ページタイトル | サイト名」という構造の場合のみです。もし「サイト名 | ページタイトル」の場合も正確に取得したいのであれば、別処理が必要になります。

また、類似度の比較に用いた60という値は体感です。数学的にはでたらめな値です。

サムネイル

次はサムネイルを取得します。これにはmetaタグのOGPを参照します。

function og_image($html) {
    if (preg_match('/<meta[^>]+?[\"\']\S*?:image[\"\'][^>]*?>/iu', $html, $thumb)) {
        preg_match('/content=[\"\'](.+?)[\"\']/iu', $thumb[0], $thumb);
        return $thumb[1];
    }
    return null;
}

本文を取得する

次に本文を取得します。これに関しては「PHP Readability」というライブラリを使います。

リンクhttps://bitbucket.org/fivefilters/full-text-rss/src

使い方は簡単で、

require_once ('/readability/Readability.php');

$readability = new Readability($html, $url);
$result = $readability->init();
if ($result !== false) {
    $title =  $readability->getContent()->innerHTML; // タイトル
    $content = $readability->getContent()->innerHTML; // 本文
}

といった感じで使います。Readabilityからは本文の他にタイトルも取得できますが、タイトルはすでに取得しているので本文の抽出のみに使います。

ReadabilityはPHPのDOMを用いて、class、id、テキスト密度といった様々な要素から各要素をポイント付けして本文を抽出しています。そのため、文字量が非常に少ないページではあまり上手く機能しません。また、元が英語なので日本語特有の要素を用いて調整すると精度の向上も少しだけ期待できます。ただ、なにもしなくても取得できるページは取得できます。

今回使うReadabilityはYouTubeやtwitchなどのiframeのみ対応しています。最近よく見かけるブログカードiframeは残したいので、本文を取得する前に全てのiframeを取り除き、本文が取得できた後に戻すという方法をとります。

本文をきれいにする

ReadabilityからはHTMLタグ付きで本文が取得できます。最後に、これを利用して本文と一緒に取得された広告などのノイズを取り除きます。

function clean_content($content) {        
    $dom = new DOMDocument;
    @$dom->loadHTML(mb_convert_encoding($content, 'HTML-ENTITIES', 'UTF-8'));
    $nodes = $dom->getElementsByTagName('*');
    foreach ($nodes as $node) {
        $class = $node->getAttribute('class');
        $node->removeAttribute('readability');

        switch (strtolower($node->tagName)) {
            case 'img':
            case 'iframe':
                $lazy_names = array (
                    'data-src',
                    'data-lazy-src',
                    'data-original',
                    'data-layzr',
                    'data-echo',
                );
                foreach($lazy_names as $name) { // lazyload対策
                    if ($node->hasAttribute($name)) {
                        $lazy_url = $node->getAttribute($name);
                        $node->removeAttribute($name);
                        $node->removeAttribute('src');
                        $node->setAttribute('src', $lazy_url);
                    }
                }
                // urlを絶対パスに変える
                $url = $node->getAttribute('src');
                $node->setAttribute('src', $this->convert_to_uri($url, $this->url));
                $node->removeAttribute('class');
                $node->removeAttribute('srcset');
                $node->removeAttribute('sizes');
                break;


            case 'div':
                if (preg_match('/(amazon|amazlet|azlink|kaereba|cstmreba|babylink)/i', $class)) {
                    $node->parentNode->removeChild($node);
                }
                break;

            case 'a':
                $url = $node->getAttribute('href');
                $node->setAttribute('href', $this->convert_to_uri($url, $this->url));
                break;

            case 'i':
                if (strpos($class, 'fa ') !== false) {
                    $node->parentNode->removeChild($node);
                }
                break;

            case 'hr':
            case 'ins':
                $node->parentNode->removeChild($node);
                break;

        }     
    }

    $content = $dom->saveHTML();
    $content = mb_convert_encoding($content, 'UTF-8', 'HTML-ENTITIES');

    $content = preg_replace( array(
        '/<!DOCTYPE[^>]*?>/i',
        '/<\/?html>/',
        '/<\/?body>/',
        '/<span[^>]*?> *?<\/span>/i',
        '/<p[^>]*?>(広告|スポンサード?リンク|[Aa]ds *?[Bb]y *?[Gg]oogle)<\/p>/i',
        '/<p[^>]*?> *?<\/p>/i',
        '/<div[^>]*?> *?<\/div>/i',
    ), '', $content);
    $this->article_content = $content;

    return $content;
}

不要な要素を取り除くついでに、画像とリンクのURLを相対パスから絶対パスに変換しておきます。変換方法はページ下部の参考記事より。また、画像とiframe要素の遅延読み込みにも対応しておきます。

スクレイピングコード完成

以上がスクレイピングするための主な関数です。あとはこれらを使ってクラスを作ります。

<?php
class Scraping {
    private $url;
    private $article_title;
    private $article_thumb;
    private $article_content;

    function __construct($url) {
        $this->url = $url;

        // html取得
        $html = $this->curl_get_contents($url);
        if($html === false) return false;
        
        // タイトルを取得
        $this->article_title = $this->get_article_title($html);
        // サムネイルを取得する
        $this->article_thumb = $this->og_image($html);
        
        // iframeを避難
        preg_match_all('/<iframe[^>]+?>.*?<\/iframe>/i', $html, $iframes);
        if ($iframes[0][0] !== null) {
            $i = 0;
            foreach($iframes[0] as $ifrm){
                $html = str_replace($ifrm, '__REPLACE_IFRAME_'.$i.'__', $html);
                $i++;
            }
        }
        // 本文を取得
        $read = new Readability($html, $url);
        $result = $read->init();
        if ($result !== false) {
            $content = $read->getContent()->innerHTML;
        } else {
            $content = '';
        }
        // iframeを戻す
        if ($iframes[0][0] !== null) {
            $i = 0;
            foreach($iframes[0] as $ifrm){         
                $content = str_replace('__REPLACE_IFRAME_'.$i.'__', $ifrm, $content);
                $i++;
            }
        }
        
        // 本文をきれいにする
        $this->article_content = $this->clean_content($content);
    }

    /* タイトル取得 */
    public function get_scraping_title() {
        return $this->article_title;
    }

    /* サムネイル取得 */
    public function get_scraping_thumb() {
        return $this->article_thumb;
    }

    /* 本文取得 */
    public function get_scraping_content() {
        return $this->article_content;
    }


    /* タイトルを取得 */
    private function get_article_title($html) {
        $title = '';
        preg_match('/<title>(.*?)<\/title>/iu', $html, $match_title);
        $title = strip_tags($match_title[1]);
        
        //「 | 」「 - 」「:」を除去
        while (preg_match('/ [\|\-\:] /', $title)) {
            $title = preg_replace('/(.+) [\|\-\:] .+/', '$1', $title);
        }
        
        $title = trim($title);
        
        $percent = 60;
        preg_match_all('/<h[1-3][^>]*?>(.*?)<\/h[1-3]>/i', $html, $match_titles);
        foreach($match_titles[1] as $match_title) {
            $match_title = trim(strip_tags($match_title));
            similar_text($title, $match_title, $per);
            if ($per > $percent) {
                $title = $match_title;
                break;
            }
        }
        
        return $title;
    }


    /* サムネイルを取得 */
    private function og_image($html) {
        if (preg_match('/<meta[^>]+?[\"\']\S*?:image[\"\'][^>]*?>/iu', $html, $thumb)) {
            preg_match('/content=[\"\'](.+?)[\"\']/iu', $thumb[0], $thumb);
            return $thumb[1];
        }
        return null;
    }


    /* curlでurl先のデータを取得する */
    private function curl_get_contents($url='') {
        if ($url=='') return false;

        $ch = curl_init();
        curl_setopt_array($ch, array(
            CURLOPT_URL => $url, //スクレイピング対象のurl
            CURLOPT_REFERER => $url, //リファラー
            CURLOPT_SSL_VERIFYPEER => false, // curlはサーバー認証をしない
            CURLOPT_RETURNTRANSFER => true, // 文字列で返す
            CURLOPT_USERAGENT => "Mozilla/5.0 (Windows; U; Windows NT 6.1; en-US) AppleWebKit/533.4 (KHTML, like Gecko) Chrome/5.0.375.125 Safari/533.4", //ユーザーエージェント
        ));
        $html = curl_exec($ch);
        // エラー処理
        if (curl_errno($ch)) {
            curl_close($ch);
            return false;
        }
        curl_close($ch);
        
        // 文字コードをUTF-8に変換
        $html = mb_convert_encoding($html, 'UTF-8', 'ASCII,JIS,UTF-8,EUC-JP,SJIS');
        
        // preタグを避難
        preg_match_all('/<pre[^>]*?>[\s\S]*?<\/pre>/', $html, $pre_codes);
        if($pre_codes[0][0] !== null) {
            $i = 1;
            foreach($pre_codes[0] as $pre) {
                $html = str_replace($pre, '__REPLACE_PRE_'.$i.'__', $html);
                $i++;
            }
        }
        // 改行をなくす
        $html = str_replace(array("\r\n", "\r", "\n"), ' ', $html);
        // preタグを戻す
        if($pre_codes[0][0] !== null) {
            $i = 1;
            foreach($pre_codes[0] as $pre) {
                $html = str_replace('__REPLACE_PRE_'.$i.'__', $pre, $html);
                $i++;
            }
        }

        // コメント、スクリプト、svgを削除
        $html = preg_replace(array(
            '/<!--[\s\S]*?-->/',
            '/<script[^>]*?>.*?<\/script>/i',
            '/<noscript[^>]*?>.*?<\/noscript>/i',
            '/<time[^>]*?>.*?<\/time>/i',
            '/<svg[^>]*?>.*?<\/svg>/i',
        ), '', $html);
        
        return $html;
    }


    /* 相対パスを絶対パスに変換する */
    private function convert_to_uri($target_path, $base) {
        $component = parse_url($base);
        $directory = preg_replace('!/[^/]*$!', '/', $component["path"]);

        switch (true) {

                // [0] 絶対パスのケース(簡易版)
            case preg_match("/^http/", $target_path):
                $uri =  $target_path;
                break;

                // [1]「//exmaple.jp/aa.jpg」のようなケース
            case preg_match("/^\/\/.+/", $target_path):
                $uri =  $component["scheme"].":".$target_path;
                break;

                // [2]「/aaa/aa.jpg」のようなケース
            case preg_match("/^\/[^\/].+/", $target_path):
                $uri =  $component["scheme"]."://".$component["host"].$target_path;
                break;

                // [3]「./aa.jpg」のようなケース
            case preg_match("/^\.\/(.+)/", $target_path,$maches):
                $uri =  $component["scheme"]."://".$component["host"].$directory.$maches[1];
                break;

                // [4]「aa.jpg」のようなケース([3]と同じ)
            case preg_match("/^([^\.\/]+)(.*)/", $target_path,$maches):
                $uri =  $component["scheme"]."://".$component["host"].$directory.$maches[1].$maches[2];
                break;

                // [5]「../aa.jpg」のようなケース
            case preg_match("/^\.\.\/.+/", $target_path):
                //「../」をカウント
                preg_match_all("!\.\./!", $target_path, $matches);
                $nest =  count($matches[0]);

                //ベースURLのディレクトリを分解してカウント
                $dir = preg_replace('!/[^/]*$!', '/', $component["path"])."\n";
                $dir_array = explode("/",$dir);
                array_shift($dir_array);
                array_pop($dir_array);
                $dir_count = count($dir_array);

                $count = $dir_count - $nest;

                $pathto="";
                $i = 0;
                while ( $i < $count) {
                    $pathto .= "/".$dir_array[$i];
                    $i++;
                }
                $file = str_replace("../","",$target_path);
                $uri =  $component["scheme"]."://".$component["host"].$pathto."/".$file;

                break;
        }
        return $uri;
    }
    
    /* 本文をきれいにする */
    private function clean_content($content) {        
        $dom = new DOMDocument;
        @$dom->loadHTML(mb_convert_encoding($content, 'HTML-ENTITIES', 'UTF-8'));
        $nodes = $dom->getElementsByTagName('*');
        foreach ($nodes as $node) {
            $class = $node->getAttribute('class');
            $node->removeAttribute('readability');

            switch (strtolower($node->tagName)) {
                case 'img':
                case 'iframe':
                    $lazy_names = array (
                        'data-src',
                        'data-lazy-src',
                        'data-original',
                        'data-layzr',
                        'data-echo',
                    );
                    foreach($lazy_names as $name) { // lazyload対策
                        if ($node->hasAttribute($name)) {
                            $lazy_url = $node->getAttribute($name);
                            $node->removeAttribute($name);
                            $node->removeAttribute('src');
                            $node->setAttribute('src', $lazy_url);
                        }
                    }
                    // urlを絶対パスに変える
                    $url = $node->getAttribute('src');
                    $node->setAttribute('src', $this->convert_to_uri($url, $this->url));
                    $node->removeAttribute('class');
                    $node->removeAttribute('srcset');
                    $node->removeAttribute('sizes');
                    break;


                case 'div':
                    if (preg_match('/(amazon|amazlet|azlink|kaereba|cstmreba|babylink)/i', $class)) {
                        $node->parentNode->removeChild($node);
                    }
                    break;
                    
                case 'a':
                    $url = $node->getAttribute('href');
                    $node->setAttribute('href', $this->convert_to_uri($url, $this->url));
                    break;

                case 'i':
                    if (strpos($class, 'fa ') !== false) {
                        $node->parentNode->removeChild($node);
                    }
                    break;

                case 'hr':
                case 'ins':
                    $node->parentNode->removeChild($node);
                    break;

            }     
        }

        $content = $dom->saveHTML();
        $content = mb_convert_encoding($content, 'UTF-8', 'HTML-ENTITIES');

        $content = preg_replace( array(
            '/<!DOCTYPE[^>]*?>/i',
            '/<\/?html>/',
            '/<\/?body>/',
            '/<span[^>]*?> *?<\/span>/i',
            '/<p[^>]*?>(広告|スポンサード?リンク|[Aa]ds *?[Bb]y *?[Gg]oogle)<\/p>/i',
            '/<p[^>]*?> *?<\/p>/i',
            '/<div[^>]*?> *?<\/div>/i',
        ), '', $content);
        $this->article_content = $content;

        return $content;
    }
}
?>

実際の使い方はこんな感じ。

$url = 'https://~~~.com/scraping'; // スクレイピング対象のurl
$scraping = new Scraping($url);
$title = $scraping->get_scraping_title(); // タイトル
$thumb = $scraping->get_scraping_thumb(); // サムネイル
$content = $scraping->get_scraping_content(); // 本文

その精度、いかほど

約100サイトでスクレイピングしてみたところ、8割強の精度でタイトル、サムネイル、本文が抽出できました。文字量が非常に少ないページのほかに、本文が小分けされているページは取得が難しいようです。ノイズに関してはあまり気にならない程度には削除できています。

さいごに

PHPでスクレイピングを試みる、はここまでです。純粋にReadabilityすごいなという感想を持ちました。

参考記事

スポンサーリンク

スポンサーリンク

コメントを残す