#はじめに
##About me
こんにちは。
「Livesense not engineers Advent Calendar 2018」の21日目を担当させて頂きます、shin_aと申します。
現在新卒2年目で、転職会議のプロダクトマネージャー及びマーケターを担当しております。
「githubってなんですか?」状態だった昨年の状態からは脱したものの、「技術について、人(初心者)に伝えられる程何も学んでない!」と焦っていた霜月の終わりに@inagakkieさんにお声がけ頂き、「終わり良ければ全て良し!12月に追い込もう」という意気込みで、参加させて頂くことにしました。
##テーマ選択の背景
・webマーケティングは、やりだすとキリがないくらい、いくらでも人員がさける
・でも、マーケティング人材は基本的に人手不足
→「botに働いて貰うのは不可避では?」という発想でトライしてみることにしました
(※2018年12月現在、知りうる限り、広告管理用APIはIndeedに存在していない。)
上司に話して見たところ、
「過去に同様の発想で自動化を試みたものの、メンテナーがいなくなって使われなくなった事例がある」と言われ、モチベーションが一瞬消えかけましたが、
「まあ、知らん。やりたいことやるのがAdvent Calendarだし、その時はその時考えよう」と気持ちを立て直し、チャレンジすることとしました。
(上司も「勉強にはなるだろうからいいんじゃない?」とは言ってました(念のため))
#概要
##本稿の対象者
● レベル感
○ 何となくruby/railsを触ったことある
(プログラミングスクールなどで短期カリキュラムこなした位)
○ クローラーとは何か、雰囲気は知ってる
(クローラーについて全くご存知なければ、宜しければこちらもご覧頂けたら嬉しいです)
● 読む目的
○ headless-chromeがどんなものか知りたい
○ seleniumがどんなものか知りたい
○ 広告管理画面に表示される指標をbotに取得させたい
##本稿のゴール
- 下記を実行できるクローラーを作成する
- Indeedの管理画面に自動でログインする
- 管理画面から、主要な広告指標(表示回数やクリック数など)をキャンペーン単位で取得する
(本当は、取得したデータをDBに突っ込んだり、スプレッドシートにエクスポートしてGASでごにょごにょして...みたいなところまで書きたかったのですが、それはまた別の機会に...)
##注意
「本稿の対象者」に書いた人物=記事を書く前の自分です(笑)
調べつつ頑張って記事を書いた程度の理解なので、諸々ツッコミどころがあるかもしれませんが、ご容赦いただけると幸いですmm
#使う技術について
##Seleniumとは?
- 自動でブラウザを操作できるオートメーションツール
- クローリングに必要な要素の取得や解析といった機能を持つ
- 「WebDriver」と呼ばれるAPIを利用しクローラとブラウザをつなぐことで、クローリングが可能になる
わかりそうで、何かよくわからないですね。
Webdriverとはなんぞやから記述してみます。
###Webdriverとは?
- driverはブラウザごとにそれぞれ存在
- chrome Driver, Firefox Driver, IE Driver など
- driverに対して指定のプロトコルでHTTPリクエストを行うことで、driverを通してブラウザを操作できる
- =curlコマンドを使って、CLI上から直接WebDriverにHTTPリクエストを送れば操作できる
- とはいえ、実際にSeleniumでクローラーを動かす際は、自分でHTTPリクエストを作成することはなく、
クライアントライブラリを利用してブラウザを操作する
文字だとよくわからないので、記事等を読んで覚えたイメージが下図です。
・クローラーを、書きたい言語(今回はRuby)で作成
・クライアントライブラリを使うことで、ブラウザ側がそれぞれ用意しているDriver(API)によしなに繋いでくれる
・クローラーが実行したいことがDriver(API)を通してブラウザに伝わり、ブラウザ操作が行われる
※あくまで自分の印象なので、より正確な情報はこちらの記事を御覧ください。
##Headless-chromeとは?
・2017/6月リリースのChrome59以降にサポートされるようになった機能
・GUIではなく、CLI上(ターミナルなど)でChromeを実行できる
「目には見えないけど裏側ではブラウザが立ち上がっている状態」だと理解してます。
先程の図において、ブラウザをChromeだけにし、調整したものが下図になります。
##「Selenium+HeadlessChrome」を採用した理由
ここで、少し脱線しますが、なぜ他のライブラリではなく「Selenium+HeadlessChrome」でのクローラ作成を考えたか記述しておきます。
- ログインなど、ステートフルなページを扱うため
- Anemoneといった、ステートレスなページのクローリングに特化したライブラリは適さないと思いました
- 管理画面が今後アップデートされた際などを想定し、少しでも対応可能性を高めたかったため
- Javascriptをベースとした技術(React.jsなど)で管理画面がアップデートされた際、レンダリングができないMechanizeでは対応が難しくなると思いました(Seleniumは実際にブラウザを操作する=レンダリングするため、対応できそう)
#実装手順
さて、ここから、実際にどんな作業をしたのか記述していきます。
##①CLIから常にChromeを実行できるようにエイリアスを設定する
先程の図でいうと、下の赤部分のあたりを設定するイメージです。
※Chromeのバージョンが59以前の場合は、最初にChromeを59以降のものにアップデートして下さい
まずは、Chromeのエイリアスを設定します。
rcファイル上(bashであれば「.bashrc」、zshであれば「.zshrc」)に、以下のようにエイリアスを設定します。
alias chrome="/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome"
これにより、CLI上で 「chrome」と入力して実行すれば、現在どのディレクトリにいようと、chromeが立ち上がるようになりました。
###(蛇足)Seleniumと絡める前に、headless-chromeで遊んで見る
chromeをheadlessモードで立ち上げる方法は簡単で、 --headless --disable-gpu
をつけるだけです。
※--disable-gpu
は、「暫定的に必要」という記述を見かけて念のためつけていますが、現在は不要かもしれないです
# chromeのブラウザが(目に見えて)立ち上がると思います
$ chrome
# chromeのブラウザがheadlessモードで立ち上がると思います(CLI上にゴニョゴニョ文字が出るはず)
$ chrome --headless --disable-gpu
もう少し、いろんなフラグを使って遊んでみます。
# 指定したURLのDOM(≒HTML)を取得できる
$ chrome --headless --disable-gpu --dump-dom https://qiita.com/
# 指定したURLのPDFを作成できる
$ chrome --headless --disable-gpu --print-to-pdf https://qiita.com/
# 指定したURLのスクリーンショットを、サイズ指定で撮れる
$ chrome --headless --disable-gpu --screenshot --window-size=1280,1696 https://qiita.com/
色々できて楽しいですね! 詳細はこちらの記事をご確認下さい。
##②Seleniumが使えるように、必要なライブラリをインストールする
本題に戻ります。下の赤色部分あたりについて、説明していきます。
といっても、chromedriver(バイナリ)とselenium-webdriver(gem)をインストールするだけです。
# chromedriverをインストールする
$ brew install chromedriver
# selenium-webdriverをインストールする
$ gem install selenium-webdriver
##③クローラーを作成する
最後に、クローラーについて簡単に記述します。ここ↓ですね。
###簡単なテストを作ってまずは動かしてみる
本稿ゴールの「Indeedの管理画面にログインし指標を取得する」クローラーを作る前に、まずは、シンプルなクローラーを作って動かしてみます。
こんな動きをするクローラーを作ってみます。
・自動でブラウザが立ち上がり、google.comにアクセスし、"qiita"と入力し検索
・検索結果ページからqiita.comにアクセス
・アクセスしたqiita.comのtitleである"qiita"が取得され、CLI上に出力
require "selenium-webdriver"
#クローラーの動きを実現するインスタンス変数を定義
driver = Selenium::WebDriver.for(:chrome)
#googleの検索ページに訪問
driver.navigate.to("http://google.com")
#'q'というnameを持つ要素(=検索窓)を取得
element = driver.find_element(:name, 'q')
# 上記で取得したinput要素に、"qiita"という文字を入力
element.send_keys("qiita")
# submitを実行(=検索する)
element.submit
# 検索結果ページで、qiitaをクリック
driver.find_element(:xpath, "//*[@id='rso']/div[1]/div/div/div/div/div[1]/a/h3").click
# 表示されたページのタイトルをコンソールに出力
puts driver.title
# テストを終了する(ブラウザを終了させる)
driver.quit
小さくてわかりにくいですが、CLI上でファイルを実行した後、自動で想定の動きがなされているのが確認できると思います。
###Indeedの管理画面の各種指標を取得するクローラーを作る
さて、Seleniumを使って無事クローラーが動くことが確認できたので、作りたいクローラーを作っていきます。
####出来上がったもの
先に、今回作成したスクリプトを載せます。
今回は、ファイルを大きく2つに分けて作成しました。
・クローラー自体のファイル...indeed_managementscreen_crawler.rb
・クローラーの実行ファイル...execute.rb
以下が作成したファイルです。
(見よう見まねで作ったので、ツッコミどころ多いと思いますがご容赦下さいmm)
require_relative 'indeed_managementscreen_crawler.rb'
require_relative 'indeed_marketingindex_importer.rb'
crawler = IndeedCrawler.new
crawler.login
crawler.gothrough
result = crawler.fetch
print result
require "selenium-webdriver"
require 'nokogiri'
require 'pry-byebug'
require 'date'
class IndeedCrawler
#ログイン時に使う名前とパスワードを環境変数から取得
LOGIN_KEY = {
"email": ENV['INDEED_EMAIL'],
"password": ENV['INDEED_PASSWORD']
}
#取得データの日付範囲指定(昨日までのデータを取得する)
TARGET_END_DATE = Date.today.prev_day(1)
def initialize()
#クローラーの動きをheadlessで実現するインスタンス変数を定義
caps = Selenium::WebDriver::Remote::Capabilities.chrome("chromeOptions" => {"args" => ["--headless"]})
@driver = Selenium::WebDriver.for(:chrome, desired_capabilities: caps)
#ドロップダウン用のインスタンス変数を定義
@selecter = Selenium::WebDriver::Support::Select
#各種指標を格納するための配列を準備
@indices = []
#キャンペーンの個数
@campaign_number = nil
end
def login
@driver.navigate.to("https://ads.indeed.com/job/ads")
##値を入力しログイン
window_email = @driver.find_element(:name, '__email')
window_email.send_keys "#{LOGIN_KEY[:email]}"
window_password = @driver.find_element(:name, '__password')
window_password.send_keys "#{LOGIN_KEY[:password]}"
@driver.find_element(:xpath, "//*[@id='loginform']/button").click
end
def gothrough
#月初来累積の差分を取りたいので、月初を指定
start_date = {
year: TARGET_END_DATE.year.to_s,
month: TARGET_END_DATE.mon.to_s,
day: "1"
}
end_date = {
year: TARGET_END_DATE.year.to_s,
month: TARGET_END_DATE.mon.to_s,
day: TARGET_END_DATE.mday.to_s
}
#絞り込みボタンを使える状態にする
@driver.find_element(:xpath, "//*[@id='filter_options']/div/span").click
#取得したい日付となるように、範囲のはじめと終わりを入力
#範囲の終わり(=昨日)
selection_end_month = @selecter.new(@driver.find_element(:xpath, "//*[@id='month2_0']"))
selection_end_month.select_by(:text, end_date[:month])
selection_end_year = @selecter.new(@driver.find_element(:xpath, "//*[@id='year2_0']"))
selection_end_year.select_by(:text, end_date[:year])
selection_end_day = @selecter.new(@driver.find_element(:xpath, "//*[@id='day2_0']"))
selection_end_day.select_by(:text, end_date[:day])
#範囲の始まり(=当月1日)
selection_start_month = @selecter.new(@driver.find_element(:xpath, "//*[@id='month1_0']"))
selection_start_month.select_by(:text, start_date[:month])
selection_start_year = @selecter.new(@driver.find_element(:xpath, "//*[@id='year1_0']"))
selection_start_year.select_by(:text, start_date[:year])
selection_start_day = @selecter.new(@driver.find_element(:xpath, "//*[@id='day1_0']"))
selection_start_day.select_by(:text, start_date[:day])
@driver.find_element(:xpath, "//*[@id='filter_options']/div/div/button").click
end
def fetch
(1..campaign_number).each do |current_campaign|
campaign = {
campaign_name: @driver.find_element(:xpath, "//*[@id='sjc_table']/tbody/tr[#{current_campaign}]/td[3]/span/span/a").text,
impressions: @driver.find_element(:xpath, "//*[@id='sjc_table']/tbody/tr[#{current_campaign}]/td[4]").text.delete("^0-9").to_i,
clicks: @driver.find_element(:xpath, "//*[@id='sjc_table']/tbody/tr[#{current_campaign}]/td[5]").text.delete("^0-9").to_i,
cost: @driver.find_element(:xpath, "//*[@id='sjc_table']/tbody/tr[#{current_campaign}]/td[9]/div[1]/span").text.delete("^0-9").to_i,
cpc: @driver.find_element(:xpath, "//*[@id='sjc_table']/tbody/tr[#{current_campaign}]/td[10]/span").text.delete("^0-9").to_i,
max_cpc: @driver.find_element(:xpath, "//*[@id='sjc_table']/tbody/tr[#{current_campaign}]/td[10]/div").text.delete("^0-9").to_i
}
#停止したキャンペーンを「取得期間においてimpが1回もないもの」として判断
if campaign[:impressions] == 0 then
next
end
@indices << campaign
end
return @indices
end
def campaign_number #行数を数えてキャンペーン数を取得
table_low_number = @driver.find_elements(:xpath, "//*[@id='sjc_table']/tbody/tr")
@campaign_number = table_low_number.count - 2
end
end
####実行方法と実行
環境変数に下記を設定します。
INDEED_EMAIL
・・・ログイン時に必要なメールアドレス
INDEED_PASSWORD
・・・ログイン時に必要なパスワード
※環境変数に関しては、こちらの記事などを参照させて頂きました。
環境変数設定後execute.rb
ファイルを実行すると、広告キャンペーン単位のハッシュを格納した配列(result)が、CLI上に表示されているのが確認できると思います。
無事、「Indeed管理画面の各種指標をクローラーで取得」できましたね!完成!
####補足
headlessモードだと、実行しても何をしているかわからないので、通常のブラウザ状態でSeleniumを動かしたいと思います。
indeed_managementscreen_crawler.rb
の、initializeメソッド内の冒頭2行を、以下のように書き換えて、execute.rb
を実行してみて下さい。
#消す→ caps = Selenium::WebDriver::Remote::Capabilities.chrome("chromeOptions" => {"args" => ["--headless"]})
@driver = Selenium::WebDriver.for(:chrome)
実行すると、下記のような挙動が確認できるはずです(ここでは静止画で、イメージ画像を貼らせて頂きますmm)
①Indeed管理画面のログインページにアクセスし、メールアドレスとパスワードを入力し、ログイン
③日付を絞り込んだページで、取得したい要素をスクレイピング
※画像は上記とほぼ同様なので、省略
#まとめ
実は、今回のクローラー作成の直前に社用PCを乗り換えていたのですが、
折角なので、Homebrewを入れたり、シェルをzshにして諸々インストールして使いやすくしたり、からやったりもしました。
(最初はそっちをカレンダーに書こうかなと思ったり)
そこも含めてなのですが、全体的に点だった知識が少しずつ線になりつつある感覚があり、世の中的にはほんの僅かなんだろうけど、個人にとってはかなり成長できたような気がしており、本当に良い勉強になりました。
(初期ポケモンが、初めて「ひのこ」や「みずでっぽう」のような属性技を覚えた的な)
Advent Calendarに参加できて良かったです。お声掛け頂きありがとうございました!
また来年も是非参加させていただきます!
そして、最後まで読んで下さった皆様、ありがとうございました!
#参考文献
###開発自体
・selenium+headlessChromeの全体感
https://qiita.com/meguroman/items/41ca17e7dc66d6c88c07
https://qiita.com/orangain/items/6a166a65f5546df72a9d
・headlessChromeについて
https://developers.google.com/web/updates/2017/04/headless-chrome
・Seleniumについて
・seleniumについて
https://app.codegrid.net/entry/selenium-1
・seleniumを使ってみる
https://qiita.com/edo_m18/items/ba7d8a95818e9c0552d9
https://qiita.com/tomerun/items/9cb81d7a98150ff22f53
・chromedriverが動かないとき
https://github.com/flavorjones/chromedriver-helper/issues/44
・webdriverの持つメソッドとか
https://qiita.com/mochio/items/dc9935ee607895420186
https://morizyun.github.io/web/selenium-cheat-sheet.html
・XpathとCSSセレクタの対応表
https://qiita.com/niusounds/items/8932790c77d781aa8993
###前提知識・周辺知識
・前提知識
・Linuxとは?カーネルとは?
https://wa3.i-3-i.info/word15502.html
・pathを通すとは?環境変数とは?
https://qiita.com/fuwamaki/items/3d8af42cf7abee760a81
・profileとは?rcとは?
https://qiita.com/shyamahira/items/260862743e4c9794b5d2
・Homebrewのcaskやtapとは?
http://tweeeety.hateblo.jp/entry/2018/05/04/181928
・「Bundler」と「gemfile」と「gemfile.lock」とについて
https://qiita.com/tsubasakat/items/169833d4c8baf79e1b52
・周辺知識
・「ChromeがStableになった」とは?
https://support.google.com/chromebook/answer/1086915?hl=ja
・PhantomJSとは?
https://jser.info/2018/06/11/phantomjs-ended/
###rubyの基礎知識
・クラスとモジュール
https://qiita.com/fukumone/items/2dd4d2d1ce6ed05928de
・インスタンス変数/クラス変数/クラスインスタンス変数
https://qiita.com/mogulla3/items/cd4d6e188c34c6819709#3--クラスインスタンス変数
・ハッシュの操作
https://uxmilk.jp/43303
・日付の扱い方
https://qiita.com/prgseek/items/c0fc2ffc8e1736348486#-n日前n日後--n月前n月後--n年前n年後
https://www.javadrive.jp/ruby/date_class/index3.html
###(蛇足)シェルの設定
・zshを入れてprezto入れた
https://qiita.com/taktakfu/items/ce228762c9078466c71f
・ターミナルをカスタマイズした
・全体
https://qiita.com/kinchiki/items/57e9391128d07819c321
・個別
・色合いを変えた
https://cocopon.me/blog/2014/04/iceberg-for-terminalapp/?p=4862
・プロンプトを変えた(pureをいれた)
https://suin.io/565
・補完機能とハイライト機能を入れた(記事の最後の方)
https://qiita.com/kinchiki/items/57e9391128d07819c321