Twitter API と Cloud Vision API を使って楽にデータを収集する

はじめに

GameWithのアドベントカレンダー2日目です。

qiita.com

やりたいこと

サービスを作ろうと思った時、データの調達がネックになることがあるかと思います。
その解決方法として、Cloud Vision API が雑にデータを集めるのに便利そうなので
ゲームのデータを例に試してみようと思います。

方法

以下の流れで進めます。
- Twitter APIで特定のハッシュタグがついているツイートを取得する
- ツイートに添付されている画像を取得する
- 画像をCloud Vision APIに投げてタイトルを取得する
- タイトルとアイコン(画像)のデータを蓄積していく

Twitter API の利用

今回対象にするハッシュタグ#あなたを作り上げたゲーム4選 です。
定期的に多くの人が投稿しており、1ツイートに4タイトル分の画像が添付されています。
デベロッパー登録をすればダッシュボードで各トークンを発行できます。
エンドポイントごとに色々と制限がついているんですが、
無料版だとSearch APIは7日以内のツイートしか検索できない制限がありました...
(宇宙の 法則が 乱れる!)

'use strict'

equire('dotenv').config()
const twitter = require('twitter')

const authConfig = {
    consumer_key: process.env.CONSUMER_API_KEY,
    consumer_secret: process.env.CONSUMER_API_SECRET_KEY,
    access_token_key: process.env.ACCESS_TOKEN,
    access_token_secret: process.env.ACCESS_TOKEN_SECRET
}

const client = new twitter(authConfig)

const main = () => {
    client.get(
        'search/tweets',
        {
            q: '#あなたを作り上げたゲーム4選',
            count: 2,  // 7日以内のツイート2件しかなかった...

        }, (error, tweets, response) => {
            if (error) {
                console.log('error', error)
            }
            for (let item in tweets.statuses) {
                let tweet = tweets.statuses[item];
                // tweet url
                console.log('https://twitter.com/'+tweet.user.screen_name+'/'+tweet.id_str)
                if (tweet.extended_entities) {
                    let urls = tweet.extended_entities.media
                    for (let i in urls) {
                        // media url
                        console.log(urls[i].media_url_https)
                    }
                }
            }
        }
    )
}

main()

Cloud Vision API の利用

ローカルにファイルを置いて指定するか、またはCloud Storageのパスを指定して利用できます。
今回は数件しかデータを引っ張ってこられなかったので、ローカルにダウンロードして簡易に試してみました。

'use strict'

async function main() {
    const vision = require('@google-cloud/vision')
  
    const client = new vision.ImageAnnotatorClient()
  
    const [result] = await client.webDetection('PATH/TO/FILE')

    console.log('description(score):')
    const webEntities = result.webDetection.webEntities
    webEntities.forEach(item => console.log(item.description+"("+item.score+")"))
  }
  
  main().catch(console.error);

結果

今回選んだテーマだと、ゲームの内容がわかりやすいパッケージ画像が多く使われていたので、
結構な高確率で正解のタイトルを導き出せていました。
PlayStation 2 などの属性データもちゃんと整理すれば、リッチなデータになりそうです。
この精度であれば、アニメ、漫画はもちろん、本や車など色々なカテゴリで利用できそうな気がします。

取得できたデータの結果例

## シャドウハーツ2
description(score):
PlayStation 2(1.564976692199707)
Shadow Hearts: Covenant(1.2087900638580322)
Shadow Hearts(1.1695201396942139)
Shadow Hearts: From the New World(1.0350451469421387)
Director's cut(0.6541000008583069)
Role-playing game(0.5997999906539917)
Role-playing video game(0.4950000047683716)
Resident Evil: Director's Cut(0.3718999922275543)

## ファイナルファンタジー10
description(score):
Final Fantasy X(1.6647182703018188)
Final Fantasy XV(0.9572311043739319)
PlayStation 2(0.7267500162124634)
Final Fantasy(0.6920100450515747)
Final Fantasy X-2(0.6174630522727966)
Role-playing game(0.5641999840736389)
Final Fantasy VI(0.5611650347709656)
Role-playing video game(0.4462999999523163)

## ルール オブ ローズ
description(score):
PlayStation 2(1.1223961114883423)
Rule of Rose(1.105049967765808)
Survival horror(0.7020999789237976)
Silent Hill(0.503849983215332)
Soundtrack(0.4196000099182129)
Survival game(0.39160001277923584)
Psychological horror(0.32030001282691956)
horror video game(0.31380000710487366)

終わりに

Twitter API の制限に引っ掛かり上手くデータの蓄積までできなかったので、
スクレイピングなど別の方法で代用して進めていこうと思います。

今回は GoogleVision AI を利用しているので、 Google Cloud Platform内で完結するのが楽な気がしています。 以下のようにほぼ Firebase を触るだけでよさそうです。

スクリプトの定期実行 = Cloud Functions
データの蓄積 = Cloud Firestore / Cloud Storage
データを使ったWebサービス = Firebase Hosting

Cloud Vision API は ↓でブラウザ上でも使えるのでみなさん試してみてください。

cloud.google.com

Cloud Datastoreに保存したSendGridのWebhookを参照するGoogle Cloud Functions API

やりたいこと

前回、SendGridのWebhookをDatastoreに貯めるところまで進めた。 tminami.hatenablog.com
今回は保存したデータを取得するAPIを、同じくGoogle Cloud Functionsでつくる。
Cloud Functionsの設定等は前記事参照。

ソース

index.js

const Buffer = require('safe-buffer').Buffer;
const Datastore = require('@google-cloud/datastore');

const BASIC_USERNAME = process.env.BASIC_USERNAME;
const BASIC_PASSWORD = process.env.BASIC_PASSWORD;
const PROJECT_ID = process.env.PROJECT_ID;
const datastore = Datastore({projectId: PROJECT_ID});

/**
 * Verify that the webhook request came from sendgrid.
 *
 * @param {string} authorization The authorization header of the request, e.g. "Basic ZmdvOhJhcg=="
 */
function verifyWebhook (authorization) {
  const basicAuth = Buffer.from(authorization.replace('Basic ', ''), 'base64').toString();
  const parts = basicAuth.split(':');
  if (parts[0] !== BASIC_USERNAME || parts[1] !== BASIC_PASSWORD) {
    const error = new Error('Invalid credentials');
    error.code = 401;
    throw error;
  }
}

/**
 * Receive a webhook from SendGrid.
 *
 * See https://sendgrid.com/docs/API_Reference/Webhooks/event.html
 *
 * @param {object} req Cloud Function request context.
 * @param {object} res Cloud Function response context.
 */
exports.getSendgridWebhook = async (req, res) => {
  try {
    if (req.method !== 'POST') {
      const error = new Error('Only POST requests are accepted');
      error.code = 405;
      throw error;
    }
    const sgMessageId = req.body['sg_message_id'] || null;
    if (!sgMessageId) {
      const error = new Error('No data error. require sg_message_id.');
      error.code = 500;
      throw error;
    }
    const isDesc = req.body['isDesc'] || false;
    const query = datastore
      .createQuery('sg_event')
      .filter('sg_message_id_prefix', '=', sgMessageId)
      .order('timestamp', {
        descending: isDesc,
      });
    const results = await datastore.runQuery(query);
    const json = JSON.stringify(results);
    
    res.setHeader('Content-Type', 'application/json');
    res.send(JSON.stringify(results));
  } catch (err) {
    console.error(err);
    res.status(err.code || 500).send(err);
  };
};

index.js

{
  "name": "get-sendgrid-webhook",
  "version": "0.0.1",
  "main": "index.js",
  "dependencies": {
    "safe-buffer": "^5.1.2",
    "@google-cloud/datastore": "1.3.4"
  }
}

検索用index作成

上記のindex.jsでは『sg_message_id_prefix』をキーにして、『timestamp』昇順降順を選んでデータを取得している。
keyValue型のデータベースだと複数のプロパティを検索条件にするときはindexを作る必要があるみたい。
今回は以下手順で作成した。

  1. Cloud Shellを開く
  2. index.yamlを作成
    $ vim index.yaml
  3. 作成したindex.yamlをもとにインデックス作成
    $ gcloud datastore create-indexes index.yaml

    index.yaml

indexes:

- kind: sg_event
  properties:
  - name: sg_message_id_prefix
  - name: timestamp

- kind: sg_event
  properties:
  - name: sg_message_id_prefix
  - name: timestamp
    direction: desc

APIを叩いてみる

curlだとこんな感じ
curl -H 'BASIC_USERNAME:****' -H 'BASIC_PASSWORD:****' -H 'Content-Type:application/json' -X POST -d '{"sg_message_id":"****","isDesc": false}' 'エンドポイントURL'

所感

実際に使うとなったらインデックスを追加して、『event』のステータスも指定する感じにするか、
APIは汎用的にしておいて呼び出し側でjsonを分解して判定するかになる。(当たり前)

SendGridのWebAPIでメールを送信した時にWebhookとの照合用IDを取得する

やりたいこと

SendGridのWebAPIコール時に、あとから送信結果照合するためのIDを取得する。
取得したIDの使い方は別記事で。

tminami.hatenablog.com

APIコール時のレスポンスヘッダー

Server: nginx
Date: Tue, 31 Jul 2018 12:31:58 GMT
Content-Type: text/plain; charset=utf-8
Content-Length: 0
Connection: close
X-Message-Id: HOge_UnHmSNCCgV9PeqHuGa
Access-Control-Allow-Origin: https://sendgrid.api-docs.io
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: Authorization, Content-Type, On-behalf-of, x-sg-elas-acl
Access-Control-Max-Age: 600
X-No-CORS-Reason: https://sendgrid.com/docs/Classroom/Basics/API/cors.html

この中の『X-Message-Id』をDBに保持しておく。

SendGridのWebhookをGoogle Cloud Functionsで受けてCloud Datastoreに貯める

やりたいこと

現在開発中のサービスではメール送信にSendGridを利用している 。
ただ、webAPIにメール送信リクエストを送って終わっているので、送信時のエラーはわかるが宛先間違いや受信メールサーバ側でのブロックまで検知できていない。
まずはSendGridのWebhookをサーバレスで貯めるところまで進める。
Node.js8とCloud Datastoreを使った例がなかったので残す。

途中までは以下と同じ
SendGrid のチュートリアル

Cloud Functionsの設定

関数作成

名前
(例)sendgrid-web-hook
※エンドポイントのURLになるのでわかりやすく
メモリ
とりあえず128MB
トリガー
HTTP
ソースコード
インラインエディタ
ブラウザで完結するのでお手軽
ランタイム
Node.js 8
※async/awaitがデフォルトで使えるようになっている
リージョン
asia-northeast1
タイムアウト
60秒
環境変数
Basic認証とCloudstore操作用に以下の3つを設定
  • BASIC_USERNAME
  • BASIC_PASSWORD
  • PROJECT_ID

SendGridの設定

ログインしてダッシュボードへ
「Settings」>「Mail Settings」>「Event Notification」>「HTTP POST URL」
ここにCloud Functionsで作成した関数のエンドポイントを指定する。
以下の記法でBasic認証も使える。
http(s)://username:password@endpoint

ソース

index.js

const Buffer = require('safe-buffer').Buffer;
const Datastore = require('@google-cloud/datastore');

const BASIC_USERNAME = process.env.BASIC_USERNAME;
const BASIC_PASSWORD = process.env.BASIC_PASSWORD;
const PROJECT_ID = process.env.PROJECT_ID;
const datastore = Datastore({projectId: PROJECT_ID});

/**
 * Verify that the webhook request came from sendgrid.
 *
 * @param {string} authorization The authorization header of the request, e.g. "Basic ZmdvOhJhcg=="
 */
function verifyWebhook (authorization) {
  const basicAuth = Buffer.from(authorization.replace('Basic ', ''), 'base64').toString();
  const parts = basicAuth.split(':');
  if (parts[0] !== BASIC_USERNAME || parts[1] !== BASIC_PASSWORD) {
    const error = new Error('Invalid credentials');
    error.code = 401;
    throw error;
  }
}

/**
 * Receive a webhook from SendGrid.
 *
 * See https://sendgrid.com/docs/API_Reference/Webhooks/event.html
 *
 * @param {object} req Cloud Function request context.
 * @param {object} res Cloud Function response context.
 */
exports.sendgridWebhook = async (req, res) => {
  try {
    if (req.method !== 'POST') {
      const error = new Error('Only POST requests are accepted');
      error.code = 405;
      throw error;
    }
    await verifyWebhook(req.get('authorization') || '');
    const events = req.body || [];
    for (const event of events) {
      event['sg_message_id_prefix'] = event['sg_message_id'].match(/^(.*?)\.filter.*/)[1];
      const key = datastore.key([
        'sg_event',
        event['sg_event_id']
      ]);
      const entity = {
        key: key,
        data: event
        
      };
      await datastore.insert(entity).catch((err) => console.error(err));
    }
    res.status(200).end()
  } catch (err) {
    console.error(err);
    res.status(err.code || 500).send(err);
  };
};

package.json

{
  "name": "sendgrid-webhook",
  "version": "0.0.1",
  "main": "index.js",
  "dependencies": {
    "safe-buffer": "^5.1.2",
    "@google-cloud/datastore": "1.3.4"
  }
}

Datastore内データイメージ

f:id:takuya_minami373:20180903171338p:plain SendGridで定義される変数+『sg_message_id_prefix』を保存する。
『sg_message_id』は【[共通部分].filter[メールアドレスごとに個別部分]】の形になっていて、
サービス側でメール送信APIを叩いたときに取得できるのは共通部分なので、検索しやすいようにカラムをわけておく。
※前方一致で無理矢理とってくることも可能だけどきれいな方法ではなかった
続きは別記事で。 tminami.hatenablog.com

Dockerでpuppeteerを動かしてフォーム予約をする

やりたいこと

結婚式に参列している最中にブラウザから申込フォームの操作をしなければいけなくなったので
crontabを使って指定日時にDockerを立ち上げ、puppeteerを操作して華麗に予約を遂行する

作業ディレクトリの作成

mkdir -p yoyaku/app/script  
cd yoyaku

ディレクトリ構成

yoyaku/
   ├ app/
   |  └ script/
   |       └yoyaku.js
   └ Dockerfile

Dockerfile

FROM node:9.2.0

RUN apt-get update \
 && apt-get install -y \
      gconf-service \
      libasound2 \
      libatk1.0-0 \
      libc6 \
      libcairo2 \
      libcups2 \
      libdbus-1-3 \
      libexpat1 \
      libfontconfig1 \
      libgcc1 \
      libgconf-2-4 \
      libgdk-pixbuf2.0-0 \
      libglib2.0-0 \
      libgtk-3-0 \
      libnspr4 \
      libpango-1.0-0 \
      libpangocairo-1.0-0 \
      libstdc++6 \
      libx11-6 \
      libx11-xcb1 \
      libxcb1 \
      libxcomposite1 \
      libxcursor1 \
      libxdamage1 \
      libxext6 \
      libxfixes3 \
      libxi6 \
      libxrandr2 \
      libxrender1 \
      libxss1 \
      libxtst6 \
      ca-certificates \
      fonts-liberation \
      libappindicator1 \
      libnss3 \
      lsb-release \
      xdg-utils \
      wget

RUN mkdir -p /puppeteer/app/script
WORKDIR /yoyaku/app
# ローカルからコンテナへ実行ファイルが入ったディレクトリをコピー
COPY app/script /yoyaku/app/script
RUN npm install puppeteer

# スクリーンショットをとるときに文字化けしないようフォントをインストール
RUN mkdir /noto
ADD https://noto-website.storage.googleapis.com/pkgs/NotoSansCJKjp-hinted.zip /noto
WORKDIR /noto
RUN apt-get install -y unzip
RUN unzip NotoSansCJKjp-hinted.zip && \
    mkdir -p /usr/share/fonts/noto && \
    cp *.otf /usr/share/fonts/noto && \
    chmod 644 -R /usr/share/fonts/noto/ && \
    fc-cache -fv
WORKDIR /
RUN rm -rf /noto

#実行するコマンド
ENTRYPOINT ["node","/yoyaku/app/script/yoyaku.js"]

参考にした記事(ありがとうございます) qiita.com morizyun.github.io

yoyaku.js

const puppeteer = require('puppeteer');
const targetUrl = "http://hogehoge.com";

//スリープ用の関数定義
async function sleep(mSec) {
  return new Promise(resolve => setTimeout(resolve, mSec));
}

(async () => {
  const browser = await puppeteer.launch({
    args: [
      '--no-sandbox',
      '--disable-setuid-sandbox'
    ]
  });

  const page = await browser.newPage();
  //ページ移動
  await page.goto(targetUrl);


//以下よく使いそうな処理の例
//-------------------ここから-------------------

  //dialogが出てきたら承認する
  page.on('dialog', async dialog => {
    console.log(dialog.message());
    await dialog.accept();
  });


  //要素のクリック + 遷移がある場合は読み込みを待つ
  page.click('#セレクタ');
  await page.waitForNavigation({timeout: 60000, waitUntil: "domcontentloaded"});

  //文字入力(セレクトボックスの選択などもできる)
  await page.type('input[name="セレクタ"]', '入力したい文字');

  //要素のvalue取得
   var value = await page.$eval('#セレクタ', item =>{
      return item.textContent;
   })

  //ログ出力
  console.log("なんか識別用の文字", 変数名);

  //スリープ(上部で定義した関数呼び出し)
  await sleep(5000);

  //スクリーンショット(実行ディレクトリに保存される)
  await page.screenshot({ path: '画像名' });

  //処理を抜ける
  process.exit();

//-------------------ここまで-------------------

  browser.close();
})();

build

docker build -t yoyaku:latest .
最後の『.』はカレントディレクトリのDockerfileを意味する

run

docker run --rm -it -v `pwd`/app/script:/yoyaku/app/script -w /yoyaku/app/script yoyaku:latest
『-v』でローカルとコンテナ内をマウントする(コンテナ内で保存したスクリーンショットを残すため)
『-w』でワーキングディレクトリを指定する

備考

crontabで設定するときはdocker runコマンドの『-it』をとらないとエラーになる

スプレッドシートで複数の置換を一度に行うGoogle Apps Script

やりたいこと

スプレッドシート上で、1つの文字列に対して複数のルールに基づいた置換を行う
SUBSTITUTEの引数に置換ルールの連想配列を渡してくるくるさせる感じ

シート

単純に3枚つかってやってみる

辞書用(dict)

A B
1 key value
2 EVXS
3 vuPV
4 3SjD 既婚
5 D53v 未婚
6 3fhq 子あり
7 kqYu 子なし

入力用(input)

A B
1 name ->
2 keys ->

出力用(output)

A B
1 name values
2

GAS

function multiSubstitute() {
  var dictSheetName = "dict";
  var inputSheetName = "input";
  var outputSheetName = "output";

  //シート取得
  var dictSheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(dictSheetName);
  var inputSheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(inputSheetName);
  var outputSheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(outputSheetName);
  
  //dict(辞書)シートから連想配列を作成
  var dictRange = dictSheet.getRange(2,1,7,2);// 2行目, 1列目(A), 7行目, 2列目(B) =A2:B7
  var dictValues = dictRange.getValues(); 
  var imDict = {};
  for each(var val in dictValues){
    imDict[val[0]] = val[1];
  }
  
  //inputシートの入力を取得
  var inputRange = inputSheet.getRange(1,2,2);// 1行目, 2列目(B), 2行目, 省略(列に変化がない) =B1:B2
  var inputValues = inputRange.getValues();
  var name = String(inputValues[0]);//名前
  var keysString = String(inputValues[1]);//キーが入った文字列 とりあえずStringにキャスト
  if(name == "" || keysString == ""){
    return;
  }
  //cookie用にurlencodeされた文字列をそのまま貼り付けてもいいようにデコード
  var keysString = keysString.match(/^%5B.*/) ? urldecode(keysString) : keysString;
  
  //辞書にあわせてkeysをvaluesに置換
  var valuesString = keys2values(keysString, imDict);
  
  //outputシートに書き出し
  outputSheet.appendRow([name,valuesString]);
  
  //inputシートの入力をクリア
  inputRange.clear();
  
  function urldecode(str){
    var decoded = decodeURI(str);
    //decodeURIだとカンマが置換されなかったので仕方なく置換
    //replaceが最初の要素しか置換しないのでカンマがなくなるまで繰り返す
    var result = decoded;
    do{
      decoded = result;
      result = decoded.replace("%2C", ",");
    }while(decoded !== result);
    return result;
  }

  //辞書の要素数だけ回してしらみつぶしに置換
  function keys2values(str, imDict){
    Object.keys(imDict).forEach(function(key){
      str = str.replace(String(key), String(this[key]));
    }, imDict);
    return str;
  }
}

結果

入力用(input)

A B
1 name -> test1
2 keys -> {"EVXS","D53v","kqYu"}

これで実行して、

出力用(output)

A B
1 name values
2 test1 {"男","未婚","子なし"}

こうなる
入出力を1シートでやる場合は、入力範囲を選択してscript実行⇒となりの列に出力とかにすればよさそう