Twitter API と Cloud Vision API を使って楽にデータを収集する
はじめに
GameWithのアドベントカレンダー2日目です。
やりたいこと
サービスを作ろうと思った時、データの調達がネックになることがあるかと思います。
その解決方法として、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 の制限に引っ掛かり上手くデータの蓄積までできなかったので、
スクレイピングなど別の方法で代用して進めていこうと思います。
今回は Google の Vision AI を利用しているので、 Google Cloud Platform内で完結するのが楽な気がしています。 以下のようにほぼ Firebase を触るだけでよさそうです。
スクリプトの定期実行 = Cloud Functions
データの蓄積 = Cloud Firestore / Cloud Storage
データを使ったWebサービス = Firebase Hosting
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を作る必要があるみたい。
今回は以下手順で作成した。
- Cloud Shellを開く
- index.yamlを作成
$ vim index.yaml
- 作成した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の使い方は別記事で。
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内データイメージ
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実行⇒となりの列に出力とかにすればよさそう