写真販売サイトから指定した人物が写った写真のみを認識してカートに入れるスクリプト【Microsoft Azure Face API】【スナップスナップ】

<img src="https://cl.ly/2t2P0m3q0t00/icon_face.png">

子供の幼稚園でイベントがある度にカメラマンさんが写真を撮ってくれて[スナップスナップ](http://snapsnap.jp)というサイトで販売されるのですが、膨大な量から自分の子供が写った写真だけを見つけるのが非常に面倒くさい。

自分の場合はMacで見れるのでまだいくらかマシなんですが、スマホだと本当に面倒くさい様ですね。

ということで、Face APIを利用して元になる顔データとアップされている写真に写っている全ての人間の顔を比較し、一致する顔があれば(==写っていれば)カートに放り込んでくれるスクリプトを書きました。

API呼び出しの回数に制限があって気軽には使えないんですが、LINE Botにも出来たらいいなと思ってます。

# 使ったもの
+ Face - Find Detect(Microsoft Azure Cognitive Services)
+ Face - Find Similar(Microsoft Azure Cognitive Services)
+ Capybara
+ Poltergeist

# 実装
個人的にスクレイピングはRubyが楽なのでRubyで。年に何度かしか使わないのでコードは超適当です。

```ruby:index.rb
#!/usr/local/bin/ruby
# coding: utf-8

require 'capybara/poltergeist'
require 'net/http'
require 'json'
require 'fastimage'

AZURE_ACCESS_KEY = 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX' # Azureの管理画面で取得
SNAPSNAP_LOGIN_ADDRESS = 'hoge@hoge.com' # スナップスナップのログインメールアドレス
SNAPSNAP_LOGIN_PASS = 'password1234' # スナップスナップのログインパスワード
SNAPSNAP_CLASS_NAME = 'くま' # 取得したい組の名前「うさぎ」「りす」とか
RESOURCE_FACE_IMAGE_URL = 'https://hoge.com/photo.jpg' # 取得したい人物が一人だけ写った写真のURL。正面からの撮影。

# 写真に含まれる顔のIDの配列を返す。API呼び出し数が限度超えてたらnil
def getFaceIds(imageUrl)
  uri = URI('https://westus.api.cognitive.microsoft.com/face/v1.0/detect')
  uri.query = URI.encode_www_form({
  })

  request = Net::HTTP::Post.new(uri.request_uri)
  request['Content-Type'] = 'application/json'
  request['Ocp-Apim-Subscription-Key'] = AZURE_ACCESS_KEY
  request.body = '{"url":"' + imageUrl + '"}';

  response = Net::HTTP.start(uri.host, uri.port, :use_ssl => uri.scheme == 'https') do |http|
    http.request(request)
  end

  case response
  when Net::HTTPSuccess, Net::HTTPRedirection then
  else
    return nil
  end

  json = JSON.parse(response.body)

  result = Array.new
  json.each do |face|
    result.push(face['faceId'])
  end

  return result
end

# 元となる顔のIDと比較する顔のIDの配列を渡し、近い顔と自信度の配列を返す。API呼び出し数が限度超えてたらnil
def getSimilarFaceExists(resouceId, targetIds)
  uri = URI('https://westus.api.cognitive.microsoft.com/face/v1.0/findsimilars')
  uri.query = URI.encode_www_form({
  })

  request = Net::HTTP::Post.new(uri.request_uri)
  request['Content-Type'] = 'application/json'
  request['Ocp-Apim-Subscription-Key'] = AZURE_ACCESS_KEY
  request.body = '{
    "faceId":"' + resouceId + '",
    "faceIds": ' + JSON.generate(targetIds) + ',
    "maxNumOfCandidatesReturned":10,
    "mode": "matchPerson"
  }';

  response = Net::HTTP.start(uri.host, uri.port, :use_ssl => uri.scheme == 'https') do |http|
    http.request(request)
  end

  case response
  when Net::HTTPSuccess, Net::HTTPRedirection then
  else
    return nil
  end

  json = JSON.parse(response.body)

  result = Array.new
  json.each do |face|
    # 0.8を超えてたら一致と判定
    if face['confidence'] > 0.8
      return true
    end
  end

  return false
end


# スナップスナップにログイン
loginId = SNAPSNAP_LOGIN_ADDRESS
loginPassword = SNAPSNAP_LOGIN_PASS

Capybara.register_driver :poltergeist do |app|
  Capybara::Poltergeist::Driver.new(app, {:js_errors => false, :timeout => 5000 })
end

session = Capybara::Session.new(:poltergeist)

session.driver.headers = {
    'User-Agent' => "Mozilla/5.0 (Windows NT 6.3; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/45.0.2454.99 Safari/537.36"
}
session.visit "http://snapsnap.jp/"

session.find("input[name='email']").native.send_key(loginId)
session.find("input[name='password']").native.send_key(loginPassword)
submit = session.find("input[alt='ログイン']")
submit.trigger('click')

# コンテンツがJSで読み込まれるのを待つ
sleep(5)

# 扱いやすいようNokogiriでパース
doc = Nokogiri::HTML.parse(session.html)

# アップされているイベントの配列
eventList = Array.new
# イベントページへのリンクを追加
doc.xpath('//div[@class="event_list ng-scope"]').each do |node|
  link = node.xpath('.//a')
  eventList.push(link.attribute("href").value)
end

# とりあえず最初のイベントのみ対象
eventList.first(1).each do |event|
  session.visit event
end

# コンテンツがJSで読み込まれるのを待つ
sleep(5)

doc = Nokogiri::HTML.parse(session.html)

# 組の配列
categoryList = Array.new

# 組や集合写真などへのリンクを全部追加
categories = doc.xpath('//div[@class="event_list"]')
categories.xpath('.//td').each do |node|
  if node.inner_text.length > 0 then
    categoryList.push({'categoryName' => node.inner_text, 'categoryUrl' => session.current_host + node.xpath('.//a').attribute("href").value})
  end
end

# とりあえず対象の組だけを対象に
categoryList.each do |category|
  if category['categoryName'] == SNAPSNAP_CLASS_NAME then
    session.visit category['categoryUrl']
    break
  end
end

# コンテンツがJSで読み込まれるのを待つ
sleep(5)

# モーダルでお知らせダイアログが開くので閉じる
a = session.find('a', text: '閉じる').click

# オートページャーなので最後まで読み込む
for num in 1..20 do
  session.execute_script("window.scrollBy(0, $(window).height())")
  sleep(0.2)
end
doc = Nokogiri::HTML.parse(session.html)

# 写真のリスト
photoList = Array.new
photos = doc.xpath('//li[@class="photo_loaded"]').each do |node|
  photo = node.xpath('.//img')
  # 写真の向きを取得しておく
  wh = FastImage.size(photo.attribute("src").value)
  anchor = node.xpath('.//p[@class="anchor"]')
  # photoidはカートに入れる時に必要
  photoList.push({'photoId' => anchor.attribute('data-photoid').value, 'thumbnailUrl' => photo.attribute("src").value, 'isVertical' => wh[0] < wh[1]})
end

# 元になる顔のIDを取得
resourceFaceId = getFaceIds(RESOURCE_FACE_IMAGE_URL)[0]

# スナップスナップ上の写真と比較。とりあえず最初の30枚のみ
photoList.first(30).each do |photo|

  # 画像URLから画像のIDを取得
  m = photo['thumbnailUrl'].match(/w1_(.+?)\//)
  str = m[1]

  # 大きいサイズの画像を取得。縦の時は最後に/3/が必要
  imageUrl = 'http://image3.photochoice.net/r/tn_' + str + '/pc_watermark_6_h/' + (photo['isVertical'] ? '3/' : '')

  # 写っておる人間全員の顔IDを取得
  faceIds = getFaceIds(imageUrl)
  # 1分に20回しか呼び出せないのでエラー出た時は回避
  if faceIds == nil then
    sleep(61)
    faceIds = getFaceIds(imageUrl)
  end

  # 横顔は認識されないことも多い
  if faceIds.length == 0 then
    next
  end

  isSimilarFaceExists = getSimilarFaceExists(resourceFaceId, faceIds)

  # 1分に20回しか呼び出せないのでエラー出た時は回避
  if isSimilarFaceExists == nil then
    sleep(61)
    isSimilarFaceExists = getSimilarFaceExists(resourceFaceId, faceIds)
  end

  # もし一人でも似ている可能性があれば
  if isSimilarFaceExists
    # カートに追加
    button = session.find(:xpath, '//li[@id="cart-add-' + photo['photoId'] + '"]')
    a = button.find(:xpath, './/a')
    a.trigger('click')

    p photo['photoId'] + 'がカートに追加されました。'
  end

end

```

# デモ
ターミナルから叩くと5分位で終わります。大体写真10枚毎に1分間に20リクエストのRate Limit超えちゃう感じですね。

<img src="https://cl.ly/1y0o052R2v1A/p02.png">

これが

<img src="https://cl.ly/45061o142i0u/p01.png">

こうなります。

<img src="https://cl.ly/1T3K0J323S1t/p03.png">

集合写真でもしっかり入りますね。

正面から写ってる写真に関してはほぼ100％カートに入りますね。

他のFace APIだと同じ人物の顔の写真をまとめてPersonというオブジェクトを作り、それを比較に使えるようなのですがFind Similarは使えないようです。

これが使えるようになるともっと精度が上がるかもしれませんね。

困っている方は是非お試しあれ。