6
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?