Facebook友達一覧からcapybara+poltergeistでクロールして居住地をリストアップする

# モチベーション
[この記事](http://unyoo.jp/2016/12/facebook_announced_partner-categories-will-be-available-in-japan/)を読んで、居住地・学歴・勤務先などの属性情報を元に機械学習してみたら面白いんじゃないかと思ったので、まずはデータを取得するところから始めたという経緯。

結局のところホントは機械学習とかしてみた結果も含めて何か知見が得られたら面白かったんだけど、思いの外facebookのクローリングの難易度が高くて苦戦しちゃって居住地の集計しかできず。

# Facebook APIとか
数年前はFacebookの公式APIで友人リストとか居住地とかもカジュアルに取れていたんだけど、いつの間にかその辺がクローズになってアプリをインストールしている友人のリストしか取れなくなってた。

そこでしょうがないのでタイトル通り、capybaraとpoltergeistでFBにログインして頑張って居住地を取ってくることにした。頑張って実装した割にはFBのhtmlが変わると動かなくなるけどそこは割り切りで。

# 実装
いろいろ試行錯誤した結果は以下の通り。

## 動作手順
以下の感じで実装した。

* メアド・パスワードを入力してFBにログインする
* トップページからプロフィールページへ遷移する
* プロフィールページから友達一覧へ遷移する
* 友達一覧はulリストが20件ずつオートロードされる仕様なのでスクロールを全ての友達が出るまで繰り返す
* 一旦全ての友達一覧から友達のURLを全て取得する
* 友達のURLに1つ1つアクセスして友達のプロフィールページに表示される自己紹介から所在地を取得する
* `名前,URL,所在地`のCSV形式で標準出力に表示する

## ソースコード
あんまり綺麗じゃないですが以下です。

```ruby
# ruby 2.3.1p112で動作確認
require 'bundler'
Bundler.require
require 'capybara/poltergeist'
require 'io/console'
require 'readline'

# タイムアウト値
MAX_WAIT_TIME = 60

# facebookの友人一覧オートロード件数
LOAD_FRIEND_COUNT = 20

# 偽装ユーザーエージェント
USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X)'

# capybaraの初期設定
Capybara.default_max_wait_time = MAX_WAIT_TIME
Capybara.register_driver :poltergeist do |app|
  Capybara::Poltergeist::Driver.new app, { timeout: MAX_WAIT_TIME }
end
Capybara.default_driver = :poltergeist
Capybara.javascript_driver = :poltergeist

def wait_until
  require 'timeout'
  Timeout.timeout MAX_WAIT_TIME do
    sleep 0.1 until value = yield
    value
  end
end

def find_intro_container(session)
  session.find '#intro_container_id'
rescue
  # 自己紹介が表示されない場合は無視
end

def friend_infos(session, friends_count)
  element = session.find '#pagelet_timeline_medley_friends'
  scroll_count = friends_count / LOAD_FRIEND_COUNT
  surplus = friends_count % LOAD_FRIEND_COUNT
  element.find 'ul'
  1.upto(scroll_count) do |i|
    session.evaluate_script 'window.scrollBy(0, 10000)'
    wait_until { element.find "ul:nth-of-type(#{i + 1})" }
  end

  (1..scroll_count + 1).map do |i|
    max_li_count = i == scroll_count + 1 ? surplus : LOAD_FRIEND_COUNT
    (1..max_li_count).map do |k|
      friend_info = element.find("ul:nth-of-type(#{i}) > li:nth-of-type(#{k}) .fsl.fwb.fcb")
      { name: friend_info.text, url: friend_info.find('a')['href'] }
    end
  end.flatten
end

def output_name_and_address(session, info)
  wait_until { session.visit info[:url] }
  intro_container = find_intro_container session
  address_class = intro_container.nil? ? nil : intro_container.all('ul > li').detect { |v| v['innerHTML'].include? '在住' }
  address = address_class.nil? ? '' : address_class.text.delete('在住')
  puts [info[:name], info[:url], address].join(',')
end

# FBログイン情報入力
puts 'Facebook login.'
print 'Email: '
email = STDIN.gets.chomp
password = STDIN.noecho { Readline.readline 'Password: ' }.tap { puts }
puts 'Start session.'

# セッション開始
session = Capybara::Session.new :poltergeist
session.driver.headers = { 'User-Agent' => USER_AGENT }

# FBログイン
session.visit 'https://www.facebook.com'
session.fill_in 'email', with: email
session.fill_in 'pass', with: password
session.find('#loginbutton').click

# プロフィールページからすべての友達一覧へ遷移
session.find('#u_0_2 > div:nth-child(1) > div:nth-child(1) > div > a').click
timeline = session.find '#fbTimelineHeadline'
friends_count = timeline.find('a:nth-of-type(3) > span._gs6').text.to_i
timeline.click_on '友達'

# 友達一覧をスクロールしてすべての友達のリストを取得
scroll_count = friends_count / LOAD_FRIEND_COUNT
friend_infos = friend_infos session, friends_count

# 友達のプロフィールページから居住地を取得
friend_infos.each do |friend_info|
  output_name_and_address session, friend_info
end
```
https://github.com/tchikuba/facebook_friends

※20で割り切れた場合の手当とかちょっと怪しい気がしてるけどそこはご愛嬌ってことで…

## 実行における注意点
何度か試行錯誤している中で、いくつか気を付けた方が良い点があったのでまとめておく。

* 既存のブラウザやモバイルアプリでFBにログインしていたのに強制ログアウトさせられたりってことがあった
    * 短時間で頻繁にログインを繰り返していると発生する現象
* UAを偽装しないとFBが不正アクセス的な警告レスポンスを返してくる
    * 実際にログインしているブラウザのコンソールでも表示されるのでちょっと驚いた
* CSSセレクタで表現が苦しいところがいくつかあるのでhtml,cssの変更には弱そう
* タイムアウトを1分とかなり長めに取っているのでかなり実行終了まで時間がかかる
    * 現状900人FB上で友人がいる自分のアカウントで試したところ4時間くらいかかった…

# 実行結果
自分のFBアカウントで試した結果から居住地の分布を出してみた。

## 公開設定で居住地を公開している人の割合
以下の通り、ほぼ半数の人が公開。
<img width="322" alt="スクリーンショット 2016-12-08 21.53.27.png" src="https://qiita-image-store.s3.amazonaws.com/0/6587/fc324db4-399c-9d38-2951-30cfab02dcb4.png">

## 公開されている人の中で居住地の割合
以下の通り。
<img width="374" alt="スクリーンショット 2016-12-08 21.37.35.png" src="https://qiita-image-store.s3.amazonaws.com/0/6587/e7bd86b4-ffa6-5d3c-75d3-7f7c94dc6e85.png">

なんかこれみて分かる通り、私、福岡出身で目黒区在住なのでまぁこんな感じですよね。
思ったより川崎に住んでる人が多いってのがちょっと意外だったかも…

## 公開されている人の中で国内・国外の割合
以下の通り。
<img width="302" alt="スクリーンショット 2016-12-08 21.53.42.png" src="https://qiita-image-store.s3.amazonaws.com/0/6587/6a339745-106b-1b1c-1e6b-59a084f77d6d.png">

純血日本人ですね。もうちょっと国際派になれるよう意識しないと…

# 今後やりたいこと

* 居住地だけじゃなくて出身とか学歴とか所属とか色々取りたい
* そういえばログアウトの実装してなかったので実装した方がお行儀は良いのかもしれない
* 本来の目的だった機械学習的なことやってみたい
* Railsでwebアプリ化して他の人も利用できるようにする
    * これでも多分FBにIPとかBANされちゃうんだろうなぁ…
    * 真面目にやるならクローリング専用EC2インスタンスを立ち上げるとかそこんところの手当をしないと(めんどくさー)
* ページによってロードに時間がかかるので1分のwaitが入るが、自己紹介がない場合など無駄なwaitも発生しているのでなんとかしたい
    * でも良い方法が思いつかない…
    * あ、タイムラインの表示をfindしてその後に自己紹介のfind入れてその場合だけタイムアウト値の敷居を下げるとか？

※アドベントカレンダー12/4(日)担当だったのに書くの遅れちゃってゴメンなさいm(_ _)m