GAS投稿2発目です!
次はTwitterのbotを作ってみたので、作り方をご紹介したいと思います。
作製したbot
「アバローのプリンセス エレナ」のbotを作ってみました。
アバローのプリンセス エレナは毎週日曜、朝7:30からテレビ東京系で放送しています。新ディズニープリンセスです。明るく元気なラテン系プリンセスです。かわいいです。ファンです。
Twitterで探してみたらまだbot無かったので、自分で作ってしまいました。
実装した機能は以下の通りです。
- 1時間に1回セリフを適当につぶやく
- メンションが来たらそれに応える
- 「フォローして」とメンションが来たら、そのユーザーをフォローし、リプライを返す
- 適当にメンションが来たら、適当にセリフをリプライする
自動投稿したい文章のリスト化
まずはスプレッドシートを用意し、つぶやかせたいセリフを大量に書き込みましょう。
(この例では、1行目をヘッダにしています)
GASのプロジェクトキーの確認
先ほどのスプレッドシートにて、スクリプトエディタを開き、「ファイル」→「プロジェクトのプロパティ」を開くと表示されます。この値を控えましょう。
Twitterでのアプリケーション登録
Twitterのアプリケーションマネージャーにアクセスし、アプリケーションを登録します。以下の図ではもう登録済みですが、「Create New App」からアプリケーションを新規登録できます。
新規登録
新規作成画面は以下となります。必要事項を入力しましょう。
今回の例だと、
- Name(アプリ名):アバローのプリンセス エレナ bot
- Description(説明文):アバローのプリンセス エレナのbotです。非公式です。アニメ中のエレナの名言を定期的につぶやきます。
- Website(ウェブサイト):Webサイトは持ち合わせていないので、適当に
https://script.google.com/macros
と入力 - Callback URL(コールバックURL):
https://script.google.com/macros/d/[プロジェクトキー]/usercallback
Callback URL
にて、先ほど控えたプロジェクトキーを使います。
パーミッションの設定
作成したアプリケーションのパーミッションを設定します。以下図のように、Permissions
タブにてRead and Write
になっているかどうかを確認してください。もしなっていない場合は変更しましょう。
コード実装
いよいよGASにてbotのコーディングを行います。ここからの作業は先ほど開いたGASエディタにて行います。
ライブラリのインポート
TwitterのAPIをGASで扱うときに、先人様の作ったありがたいライブラリが提供されております。ありがたく使わせていただきましょう。
「リソース」→「ライブラリ」と選択すると、以下図のようなダイアログが出現するので、テキストボックスにMb2Vpd5nfD3Pz-_a-39Q4VfxhMjh3Sh48
と入力し、追加を押すとライブラリをインポートできます。(バージョンは7がよさそう)
先人様ありがとうございます。
Twitter.gsの作成
ユーティリティコードを記述しておきます。参考サイトほぼそのままです。
途中で出てくるprojectKey
、consumerKey
、consumerSecret
にはそれぞれ環境に合わせた値を入力する必要があります。projectKey
には、先ほど控えたプロジェクトキーを、consumerKey
とconsumerSecret
にはアプリケーションマネージャーにて表示されている値を入力します。
Twitter.gs
// 最初にこの関数を実行し、ログに出力されたURLにアクセスしてOAuth認証する function twitterAuthorizeUrl() { Twitter.oauth.showUrl(); } // OAuth認証成功後のコールバック関数 function twitterAuthorizeCallback(request) { return Twitter.oauth.callback(request); } // OAuth認証のキャッシュをを削除する場合はこれを実行(実行後は再度認証が必要) function twitterAuthorizeClear() { Twitter.oauth.clear(); } var Twitter = { projectKey: "xxxxxxxxxxxxxxxxxxxxxxxxxxxx", consumerKey: "xxxxxxxxxxxxxxxxxxxxxxxxx", consumerSecret: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", apiUrl: "https://api.twitter.com/1.1/", oauth: { name: "twitter", service: function(screen_name) { // 参照元:https://github.com/googlesamples/apps-script-oauth2 return OAuth1.createService(this.name) // Set the endpoint URLs. .setAccessTokenUrl('https://api.twitter.com/oauth/access_token') .setRequestTokenUrl('https://api.twitter.com/oauth/request_token') .setAuthorizationUrl('https://api.twitter.com/oauth/authorize') // Set the consumer key and secret. .setConsumerKey(this.parent.consumerKey) .setConsumerSecret(this.parent.consumerSecret) // Set the project key of the script using this library. .setProjectKey(this.parent.projectKey) // Set the name of the callback function in the script referenced // above that should be invoked to complete the OAuth flow. .setCallbackFunction('twitterAuthorizeCallback') // Set the property store where authorized tokens should be persisted. .setPropertyStore(PropertiesService.getUserProperties()); }, showUrl: function() { var service = this.service(); if (!service.hasAccess()) { Logger.log(service.authorize()); } else { Logger.log("認証済みです"); } }, callback: function (request) { var service = this.service(); var isAuthorized = service.handleCallback(request); if (isAuthorized) { return HtmlService.createHtmlOutput("認証に成功しました!このタブは閉じてかまいません。"); } else { return HtmlService.createHtmlOutput("認証に失敗しました・・・"); } }, clear: function(){ OAuth1.createService(this.name) .setPropertyStore(PropertiesService.getUserProperties()) .reset(); } }, api: function(path, data) { var that = this, service = this.oauth.service(); if (!service.hasAccess()) { Logger.log("先にOAuth認証してください"); return false; } path = path.toLowerCase().replace(/^\//, '').replace(/\.json$/, ''); var method = ( /^statuses\/(destroy\/\d+|update|retweet\/\d+)/.test(path) || /^media\/upload/.test(path) || /^direct_messages\/(destroy|new)/.test(path) || /^friendships\/(create|destroy|update)/.test(path) || /^account\/(settings|update|remove)/.test(path) || /^blocks\/(create|destroy)/.test(path) || /^mutes\/users\/(create|destroy)/.test(path) || /^favorites\/(destroy|create)/.test(path) || /^lists\/[^\/]+\/(destroy|create|update)/.test(path) || /^saved_searches\/(create|destroy)/.test(path) || /^geo\/place/.test(path) || /^users\/report_spam/.test(path) ) ? "post" : "get"; var url = this.apiUrl + path + ".json"; var options = { method: method, muteHttpExceptions: true }; if ("get" === method) { if (!this.isEmpty(data)) { url += '?' + Object.keys(data).map(function(key) { return that.encodeRfc3986(key) + '=' + that.encodeRfc3986(data[key]); }).join('&'); } } else if ("post" == method) { if (!this.isEmpty(data)) { options.payload = Object.keys(data).map(function(key) { return that.encodeRfc3986(key) + '=' + that.encodeRfc3986(data[key]); }).join('&'); if (data.media) { options.contentType = "multipart/form-data;charset=UTF-8"; } } } try { var result = service.fetch(url, options); var json = JSON.parse(result.getContentText()); if (json) { if (json.error) { throw new Error(json.error + " (" + json.request + ")"); } else if (json.errors) { var err = []; for (var i = 0, l = json.errors.length; i < l; i++) { var error = json.errors[i]; err.push(error.message + " (code: " + error.code + ")"); } throw new Error(err.join("\n")); } else { return json; } } } catch(e) { this.error(e); } return false; }, error: function(error) { var message = null; if ('object' === typeof error && error.message) { message = error.message + " ('" + error.fileName + '.gs:' + error.lineNumber +")"; } else { message = error; } Logger.log(message); }, isEmpty: function(obj) { if (obj == null) return true; if (obj.length > 0) return false; if (obj.length === 0) return true; for (var key in obj) { if (hasOwnProperty.call(obj, key)) return false; } return true; }, encodeRfc3986: function(str) { return encodeURIComponent(str).replace(/[!'()]/g, function(char) { return escape(char); }).replace(/\*/g, "%2A"); }, init: function() { this.oauth.parent = this; return this; } }.init(); /******************************************************************** 以下はサポート関数 */ // ツイート検索 Twitter.search = function(data) { if ("string" === typeof data) { data = {q: data}; } return this.api("search/tweets", data); }; // 自分のタイムライン取得 Twitter.tl = function(since_id) { var data = null; if ("number" === typeof since_id || /^\d+$/.test(''+since_id)) { data = {since_id: since_id}; } else if("object" === typeof since_id) { data = since_id; } return this.api("statuses/home_timeline", data); }; // ユーザーのタイムライン取得 Twitter.usertl = function(user, since_id) { var path = "statuses/user_timeline"; var data = {}; if (user) { if (/^\d+$/.test(user)) { data.user_id = user; } else { data.screen_name = user; } } else { var path = "statuses/home_timeline"; } if (since_id) { data.since_id = since_id; } return this.api(path, data); }; // フォロワー取得 Twitter.followers = function(user) { var path = "followers/ids"; var data = {} if (user) { if (/^\d+$/.test(user)) { data.user_id = user; } else { data.screen_name = user; } } else { data.screen_name = "elena_bot161127"; } return this.api(path, data); } // フォロー取得 Twitter.friends = function(user) { var path = "friends/ids"; var data = {} if (user) { if (/^\d+$/.test(user)) { data.user_id = user; } else { data.screen_name = user; } } else { data.screen_name = "elena_bot161127"; } return this.api(path, data); } // ツイートする Twitter.tweet = function(data, reply) { var path = "statuses/update"; if ("string" === typeof data) { data = {status: data}; } else if(data.media) { path = "statuses/update_with_media "; } if (reply) { if("string" === typeof reply) { data.in_reply_to_status_id_str = reply; } else { data.in_reply_to_status_id = reply; } } return this.api(path, data); }; // トレンド取得(日本) Twitter.trends = function(woeid) { data = {id: woeid || 1118108}; var res = this.api("trends/place", data); return (res && res[0] && res[0].trends && res[0].trends.length) ? res[0].trends : null; }; // トレンドのワードのみ取得 Twitter.trendWords = function(woeid) { data = {id: woeid || 1118108}; var res = this.api("trends/place", data); if (res && res[0] && res[0].trends && res[0].trends.length) { var trends = res[0].trends; var words = []; for(var i = 0, l = trends.length; i < l; i++) { words.push(trends[i].name); } return words; } }; Twitter.replies = function(last_id){ if(last_id) { return Twitter.api('statuses/mentions_timeline',{'since_id':last_id}); } else { return Twitter.api('statuses/mentions_timeline'); } }
OAuth認証
ここで、TwitterにOAuth認証します。
認証用の関数の中から、twitterAuthorizeUrl
というのを選択し、実行します。
関数が終了しても特に画面は変化しませんが、上部メニュー「表示」→「ログ」を開くと承認用のURLが出力されています。 これをコピーしてブラウザでアクセスすると、Twitterのアプリ承認画面に行くので承認してください。
承認後は画面が切り替わって「認証に成功しました」という文字が出現します。
軽く動作テスト
APIがちゃんと叩けるかどうかテストしてみましょう。
以下の関数を定義し、実行してみてください。
コード.gs
function tweet_test() { // ツイートする var res = Twitter.tweet("ツイート文"); }
これで、認証を通したアカウントにツイート文が表示されれば、APIを正常に叩けていることになります。
Twitter.tweet("")
とするだけでツイートできるとか楽すぎます。
botの動作をコーディング
実際のbotのコードですが、今回は以下のようにコーディングしました。
コード.gs
// 定期つぶやき function elena_bot() { elena_tweet(); } // タイムライン処理 function elena_mentions() { var last_id = PropertiesService.getScriptProperties().getProperty("last_id"); if(last_id) { var mentions = Twitter.replies(last_id); } else { var mentions = Twitter.replies(); } if(mentions.length>0&&'id_str' in mentions[0]){ PropertiesService.getScriptProperties().setProperty("last_id", mentions[0]['id_str']); //最新のIDを保存しておく } else { return; } for(var i = 0, l = mentions.length; i < l; i++) { if(mentions[i]["text"].match(/フォローして/)) { // フォローしてと言われたら、フォローする Twitter.api("friendships/create", {user_id: mentions[i]["user"]["id_str"]}); Twitter.tweet("@" + mentions[i]["user"]["screen_name"] + " フォローしたわよ!あなたも一緒にアバロー王国の平和を守りましょう!", mentions[i]['id_str']); } else { // その他のリプは適当に返す elena_tweet(mentions[i]['id_str'], mentions[i]["user"]["screen_name"]); } } } // フォローを解除されたらこちらも解除する function unfollow() { var follows = Twitter.friends(); var followers = Twitter.followers(); var comp = {}; for(var i = 0, l = followers["ids"].length; i < l; i++) { comp[followers["ids"][i]] = 1; } for(var i = 0, l = follows["ids"].length; i < l; i++) { if(!comp[follows["ids"][i]]) Twitter.api("friendships/destroy", {user_id: follows["ids"][i].toString()}); } } // ツイート処理 function elena_tweet(tweet_id, screen_name) { var ash = SpreadsheetApp.getActiveSpreadsheet(); var sheet = ash.getActiveSheet(); var lastRow = sheet.getLastRow(); var random = Math.floor( Math.random() * (lastRow-2))+2; if(tweet_id && screen_name) { var res = Twitter.tweet("@" + screen_name + " " + sheet.getRange(random, 1).getValue(), tweet_id); } else { var res = Twitter.tweet(sheet.getRange(random, 1).getValue()); } }
トリガーの設定
エディタ上の時計マークのボタンからトリガーを設定します。時間は適当です。お好みで。
動作確認
1時間に1回ツイートするようにしました。
トラブルシュート
いざコードを実装し、何の問題もなくスムーズに動作したかというと、そういう訳ではありませんでした。レスポンスをLogger.log(res);
してみると、以下のようなエラーに遭遇しました。その解決方法についても書き記しておこうと思います。
Read-only application cannot POST
まず、Twitterにアカウントを作成した場合、Emailでの本人確認を行う必要がありますが、それをやってなかったので、アプリケーションにWriteの権限が付与されておりませんでした。(アホかと。。。)
本人確認をして、前述したパーミッションの設定でRead and Write
の権限を付与してあげれば、こちらは解決しました。
Invalid or expired token (code:89)
次にこのエラーがでました。本人確認をする前に認証を通してしまったので、本人確認することでアクセストークンが期限切れになってしまったものと思っております。
これについては、twitterAuthorizeClear()
を実行し、再度twitterAuthorizeUrl()
の手順を行えば解決しました。
まとめ
GASでTwitterのbotを作るのはライブラリもあるので想像以上に敷居が低かったです。スプレッドシートとの組み合わせで、簡単な分析なんかもできそうですね。是非皆さんもトライしてみてください。
コメント