LoginSignup
6
4

More than 5 years have passed since last update.

初心者がSlack+Hubot(+Heroku)でPHPマニュアルをスクレイピングする(後編)

Last updated at Posted at 2015-03-25

前編でプレビューが表示されずに絶望しましたが、
仕方がないのでスクレイピングというものに挑戦します。
とはいってもだいたい落ちてるコードの切り貼りですが。

長くなりましたが8割は同じコードを載せているためです。

cheerioのインストール

素のHTMLではとても切り出す気力がわかないのでセレクタっぽく扱えるらしいcheerioを入れます。cheerio-httpcliと迷いましたが、初めてなので基本の方からで。

インストール
npm i cheerio
npm list

普段からcomposerも使っていないのでインストールは何でもドキドキします。

npm ERR! extraneous: cheerio@0.18.0

エラーでました。 --saveが無くpackage.jsonに追記されていないみたいです。
慣れたと思ってコピペをやめるとこれですね。
手動追加もいいですが、せっかく簡単にしてくれているので入れ直します。

package.jsonへの追記ありインストール
npm uninstall cheerio
npm install cheerio --save

セレクタでの取得

取得内容はユーザーノートより上としましょう。
開発者ツールで要素を調査し抜き出します。

requireを忘れてはいけない(1敗)

HTMLから一部だけ取り出す
cheerio = require 'cheerio'

module.exports = (robot) ->
    robot.hear /php man (.+)$/, (msg) ->
        baseUrl = "http://php.net/manual-lookup.php?lang=ja&scope=quickref&pattern="
        baseUrl += msg.match[1]
        robot.http(baseUrl).get() (err, res, body) ->
            if res.headers.location?
                # 一致する結果があった
                msg.send res.headers.location
                robot.http(res.headers.location).get() (err, res, body) ->
                    $ = cheerio.load body
                    msg.send $('div.book').text()
            else
                msg.send "のっと☆ふぁうんど!"

イケテナイ.JPG

イケてないですね。改行などが無いのはもちろん、関数を表示させるつもりブックを表示させているのですから。
arrayなんて間際らしいワードにするんじゃなかったと今更後悔しています。
しかたないので、ちょっとセレクタとマークダウンの勉強をして整形します。

マークダウンで書いてみたが……

bookをマークダウンで出力する
cheerio = require 'cheerio'

module.exports = (robot) ->
    robot.hear /php man (.+)$/, (msg) ->
        message = ''
        baseUrl = "http://php.net/manual/ja/"
        searchUrl = "http://php.net/manual-lookup.php?lang=ja&scope=quickref&pattern="
        searchUrl += msg.match[1]
        robot.http(searchUrl).get() (err, res, body) ->
            if res.headers.location?
                # 一致する結果があった
                message += res.headers.location + "\n"
                robot.http(res.headers.location).get() (err, res, body) ->
                    $ = cheerio.load body
                    message += '# ' + $('h1.title').text() + "\n\n"

                    #親のdivまで指定しなければchunklist_childrenも含んでしまう
                    $('div>ul.chunklist.chunklist_book>li').each ->
                        li = $ @
                        a = li.children('a')
                        link = '[' + a.text() + '](' + baseUrl + a.attr('href') + ')'
                        # liのテキストのみ取得するいい方法がわからなかった…
                        li_text = `li.first().contents().filter(function() {
                            return this.type === 'text';
                        }).text()`
                        message += '* ' + link + li_text + "\n"

                        #入れ子は多分一回
                        li.children('ul').each ->
                            #孫要素は直接指定できない?
                            ui = $ @
                            ui.children('li').each ->
                                children = $ @
                                a = children.children('a')
                                children_text = `children.first().contents().filter(function() {
                                    return this.type === 'text';
                                }).text()`
                                link = '[' + a.text() + '](' + baseUrl + a.attr('href') + ')'
                                message += '** ' + link + children_text + "\n"
                    #ここ以外だと出力されない…
                    msg.send message

            else
                msg.send "のっと☆ふぁうんど!"

一気に泥臭くなりました。
shellでマークダウンっぽい出力を確認ののちにコミット&プッシュで確認します。

生じゃないか.JPG

…そのままです。
慌てて調べたらslackのデフォルトで使えるマークダウンは一部で、全て使うにはPostで作らないといけない模様。
試しでテストしてみればよかったのですが、Herokuに送るためにはコミットしなければならないのでどうしても完成形を目指してしまいます。
巻き戻しが簡単とは言え少し悩みます。
<URL|文字>形式も試してみましたが効かないので、文字リンクは諦めます…

そこからURLを普通に出力し、リストの入れ子を半角スペースのインデントで表現しましたが…

  • スペースが取り除かれ入れ子が表現できない
  • ```を使うとスペースがそのまま出力されるが強調などが使えない
    • 出力が長すぎる(arrayなど)ものは```が評価されない

などなどがあり、結局最初のリストは「*」入れ子のリストは「-*」にしました。み、見辛い……

苦肉
cheerio = require 'cheerio'

module.exports = (robot) ->
    robot.hear /php man (.+)$/, (msg) ->
        message = ''
        baseUrl = "http://php.net/manual/ja/"
        searchUrl = "http://php.net/manual-lookup.php?lang=ja&scope=quickref&pattern="
        searchUrl += msg.match[1]
        robot.http(searchUrl).get() (err, res, body) ->
            if res.headers.location?
                # 一致する結果があった
                message += res.headers.location + "\n"
                robot.http(res.headers.location).get() (err, res, body) ->
                    $ = cheerio.load body
                    message += '*' + $('h1.title').text() + "*\n\n"

                    #親のdivまで指定しなければchunklist_childrenも含んでしまう
                    $('div>ul.chunklist.chunklist_book>li').each ->
                        li = $ @

                        a = li.children('a')
                        # liのテキストのみ取得するいい方法がわからなかった…
                        li_text = `li.first().contents().filter(function() {
                            return this.type === 'text';
                        }).text()`
                        message += '* ' + a.text() + li_text + "\n"
                        message += '- ' + baseUrl + a.attr('href') + "\n"

                        #入れ子は多分一回
                        li.children('ul').each ->
                            #孫要素は直接指定できない?
                            ui = $ @
                            ui.children('li').each ->
                                children = $ @
                                a = children.children('a')
                                children_text = `children.first().contents().filter(function() {
                                    return this.type === 'text';
                                }).text()`
                                message += '-* ' + a.text() + children_text + "\n"
                                message += '-- ' + baseUrl + a.attr('href') + "\n"
                    #ここ以外だと出力されない…
                    msg.send message

            else
                msg.send "のっと☆ふぁうんど!"

つらりすと.JPG

まぁ、リスト型のページなんてそんなに来る頻度は高くないでしょう。
次は肝心な関数ページをスクレイピングします

関数ページのスクレイピング

後は目新しい要素はありません
関数のページはHTMLの構造も変わります。
URLも変わるので、res.headers.locationに含まれている文字列でどの種類のページか判定します。
関数ページのURLは
http://php.net/manual/ja/function.strlen.php
/function.(関数名).php
なので、URLに/function.があれば関数のページとします。
後はswitchで分岐してスクレイピングです。

bookで体感しましたが、長すぎる表示は邪魔なので簡単な説明のみ表示します。

関数のスクレイピング
cheerio = require 'cheerio'

module.exports = (robot) ->

    robot.hear /php man (.+)$/, (msg) ->
        message = ''
        baseUrl = "http://php.net/manual/ja/"
        searchUrl = "http://php.net/manual-lookup.php?lang=ja&scope=quickref&pattern="
        searchUrl += msg.match[1]
        robot.http(searchUrl).get() (err, res, body) ->
            if res.headers.location?
                # 一致する結果があった
                message += res.headers.location + "\n"
                switch true
                    when res.headers.location.indexOf('/book.') != -1
                        robot.http(res.headers.location).get() (err, res, body) ->
                            $ = cheerio.load body
                            message += '*' + $('h1.title').text() + "*\n\n"

                            #親のdivまで指定しなければchunklist_childrenも含んでしまう
                            $('div ul.chunklist.chunklist_book li').each ->
                                li = $ @

                                a = li.children('a')
                                # liのテキストのみ取得するいい方法がわからなかった…
                                li_text = `li.first().contents().filter(function() {
                                    return this.type === 'text';
                                }).text()`
                                message += '* ' + a.text() + li_text + "\n"
                                message += '- ' + baseUrl + a.attr('href') + "\n"

                                #入れ子は多分一回
                                li.children('ul').each ->
                                    #孫要素は直接指定できない?
                                    ui = $ @
                                    ui.children('li').each ->
                                        children = $ @
                                        a = children.children('a')
                                        children_text = `children.first().contents().filter(function()  {
                                            return this.type === 'text';
                                        }).text()`
                                        message += '-* ' + a.text() + children_text + "\n"
                                        message += '-- ' + baseUrl + a.attr('href') + "\n"
                            #ここ以外だと出力されない…
                            msg.send message
                    when res.headers.location.indexOf('/function.') != -1
                        robot.http(res.headers.location).get() (err, res, body) ->
                            $ = cheerio.load body
                            message += '*' + $('h1.refname').text() + '*' + "\n"
                            message += $('p.refpurpose').text() + "\n"
                            message += '*' + $('div.refsect1.description h3.title').text() + '*' + "\n"
                            message += '`' + $('div.methodsynopsis.dc-description').text().replace(/\n/g, '').replace(/\s+/g, ' ') + '`' + "\n"
                            message += $('p.para.rdfs-comment').text() + "\n"

                            msg.send message

                    when res.headers.location.indexOf('/class.') != -1
                        message += 'そのうち' + "\n"
                        msg.send message
                    when res.headers.location.indexOf('/language.') != -1
                        message += 'そのうち' + "\n"
                        msg.send message
                    when res.headers.location.indexOf('/reserved.') != -1
                        message += 'そのうち' + "\n"
                        msg.send message
                    else
                        message += '非対応のURL' + "\n"
                        msg.send message

            else
                msg.send "のっと☆ふぁうんど!"

先ほどまでのスクレイピングを/book.に移動。
見つけたURLの形式は今後対応ということで型だけ作っておきます。(coffeescriptはno breakできない?)
$('div.methodsynopsis.dc-description')は関数の構文ですが、出力時に改行やスペースが入ることがあったので正規表現で調整してみました。

functionゲットだZE.JPG

装飾によっては説明文に改行などが入ったり、
var_dump()の説明文のHTMLがほかと違っていて対応していなかったりとありますが、概ね満足です。

おまけのもしかして

最後に、存在しない検索ワードを入れた場合のもしかしてページをスクレイピングします。これで誤字っても安心になるかも。
書き換え場所はmsg.send "のっと☆ふぁうんど!"の所です。

一致しなかった場合のスクレイピング
cheerio = require 'cheerio'

module.exports = (robot) ->

    robot.hear /php man (.+)$/, (msg) ->
        message = ''
        baseUrl = "http://php.net/manual/ja/"
        searchUrl = "http://php.net/manual-lookup.php?lang=ja&scope=quickref&pattern="
        searchUrl += msg.match[1]
        robot.http(searchUrl).get() (err, res, body) ->
            if res.headers.location?
                # 一致する結果があった
                message += res.headers.location + "\n"
                switch true
                    when res.headers.location.indexOf('/book.') != -1
                        robot.http(res.headers.location).get() (err, res, body) ->
                            $ = cheerio.load body
                            message += '*' + $('h1.title').text() + "*\n\n"

                            #親のdivまで指定しなければchunklist_childrenも含んでしまう
                            $('div ul.chunklist.chunklist_book li').each ->
                                li = $ @

                                a = li.children('a')
                                # liのテキストのみ取得するいい方法がわからなかった…
                                li_text = `li.first().contents().filter(function() {
                                    return this.type === 'text';
                                }).text()`
                                message += '* ' + a.text() + li_text + "\n"
                                message += '- ' + baseUrl + a.attr('href') + "\n"

                                #入れ子は多分一回
                                li.children('ul').each ->
                                    #孫要素は直接指定できない?
                                    ui = $ @
                                    ui.children('li').each ->
                                        children = $ @
                                        a = children.children('a')
                                        children_text = `children.first().contents().filter(function()  {
                                            return this.type === 'text';
                                        }).text()`
                                        message += '-* ' + a.text() + children_text + "\n"
                                        message += '-- ' + baseUrl + a.attr('href') + "\n"
                            #ここ以外だと出力されない…
                            msg.send message
                    when res.headers.location.indexOf('/function.') != -1
                        robot.http(res.headers.location).get() (err, res, body) ->
                            $ = cheerio.load body
                            message += '*' + $('h1.refname').text() + '*' + "\n"
                            message += $('p.refpurpose').text() + "\n"
                            message += '*' + $('div.refsect1.description h3.title').text() + '*' + "\n"
                            message += '`' + $('div.methodsynopsis.dc-description').text().replace(/\n/g, '').replace(/\s+/g, ' ') + '`' + "\n"
                            message += $('p.para.rdfs-comment').text() + "\n"

                            msg.send message

                    when res.headers.location.indexOf('/class.') != -1
                        message += 'そのうち' + "\n"
                        msg.send message
                    when res.headers.location.indexOf('/language.') != -1
                        message += 'そのうち' + "\n"
                        msg.send message
                    when res.headers.location.indexOf('/reserved.') != -1
                        message += 'そのうち' + "\n"
                        msg.send message
                    else
                        message += '非対応のURL' + "\n"
                        msg.send message

            else
                # 一致する結果がなかったので親しいと思われる関数リストを上から5件表示
                message += 'http://php.net' + res.req.path + "\n"
                $ = cheerio.load body
                message += '*' + $('section#layout-content h1').text() + '*' + "\n"
                message += $('section#layout-content p').text() + '上位5件を表示します' + "\n"
                i = 0
                $('ul#quickref_functions li').each ->
                    return false if i == 5
                    li = $ @
                    a = li.children('a')
                    message += '* ' + a.text() + ' - ' + 'http://php.net' + a.attr('href') + "\n"
                    i++
                msg.send message

bookの反省を活かし上位5件までを表示します。
レスポンスからのURLの取得方法やeachの抜け方にもやっとしてます。

もしかして.JPG

感想

BOTが反応してくれて楽しかった(小並感)

コールバックや変数のスコープがイマイチわかっていない。

Shellで動くのにHeroku上では動かなかったり逆のパターンが一度ずつあって少し困った。
Herokuで動かない理由は全角スペースの混入のためだった。

Q.このコマンド使うの?
A.実際はPHPStormのクイックリファレンスやググる方が多そう。
でもコードの話をしているときにさっくり関数の説明を共有できるといいかも。
だからSlackの布教を頑張って(現在1名)

Q.各技術の理解が深まった?
A.そんなに。理解不要で導入出来るほどの記事の多さがメリットですし、第一その手軽さがなかったらチャレンジしてませんし。
でも「動作するモノ」が手元にあるってことは学習意欲を掻き立てられていいと思います。

Q.コード汚すぎない?
A.はい。

6
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
4