Google Apps Script(GAS)で、スプレッドシートからX(旧Twitter) API v2経由でツイッターに自動投稿するTwitter botの作成方法を備忘録として記載。
前回、下記の記事でTwitter Bot作成のためのTwitter API認証までを終えた。まだ設定終わっていない方は下記記事から設定してください。
それでは、実際に、テキスト投稿・画像投稿を進めていこうと思う。
テキスト投稿
第一ステップ:GASスクリプト内のテキスト表示
まず、第一ステップとして、Googleスプレッドシートからではなく、GASスクリプト内に直接Twitter投稿テキストを埋め込んで、twitterに投稿されるかどうかを検証する。
前回のTwitter API認証で用いたmain関数の中身を下記のように変更。関数名もsentTweet()に変更(必須ではない)
こちらのmain()関数を
function main() {
const service = getService();
if (service.hasAccess()) {
Logger.log("Already authorized");
} else {
const authorizationUrl = service.getAuthorizationUrl();
Logger.log('Open the following URL and re-run the script: %s', authorizationUrl);
}
}
下記のsendTweet()関数へと拡張
function sendTweet() {
var payload = {
text: 'Test tweet from API!!!!!'
}
var service = getService();
if (service.hasAccess()) {
var url = `https://api.twitter.com/2/tweets`;
var response = UrlFetchApp.fetch(url, {
method: 'POST',
'contentType': 'application/json',
headers: {
Authorization: 'Bearer ' + service.getAccessToken()
},
muteHttpExceptions: true,
payload: JSON.stringify(payload)
});
var result = JSON.parse(response.getContentText());
Logger.log(JSON.stringify(result, null, 2));
} else {
var authorizationUrl = service.getAuthorizationUrl();
Logger.log('Open the following URL and re-run the script: %s',authorizationUrl);
}
}
payloadという変数を用意して、「Test tweet from API!!!!!」というテキストをtwitter botとして投稿するように代入している。
終えたら、GASスクリプトを保存して、実行関数をsentTweetに変更して実行。
すると、GASの実行ログに下記のように表示されて、twitterアカウントでもtweetが表示された。
Googleスプレッドシートからテキストをtweet
それでは、いよいよGoogleスプレッドシートからテキストをtweetする準備をしていく。
まず、今回のスクリプトは、スプレッドシートに下記のカラムを用意
・文字数はtwitter投稿の文字数制限を管理するためのもので、スクリプト的には不要なのでなくても良い
・image_urlもwebに公開されている画像を投稿したいときのものなので、画像投稿しない場合は不要
C列の「投稿内容」カラムにtwitter投稿ようの文言を適当に入れる。
下記のGASスクリプトをコピペ
// CLIENT_IDと、CLIENT_SECRETはtwitter developerサイトから取得した値を用いる
const CLIENT_ID = 'XXXX'
const CLIENT_SECRET = 'XXXXX'
function getService() {
pkceChallengeVerifier();
const userProps = PropertiesService.getUserProperties();
const scriptProps = PropertiesService.getScriptProperties();
return OAuth2.createService('twitter')
.setAuthorizationBaseUrl('https://twitter.com/i/oauth2/authorize')
.setTokenUrl('https://api.twitter.com/2/oauth2/token?code_verifier=' + userProps.getProperty("code_verifier"))
.setClientId(CLIENT_TEST_ID)
.setClientSecret(CLIENT_TEST_SECRET)
.setCallbackFunction('authCallback')
.setPropertyStore(userProps)
.setScope('users.read tweet.read tweet.write offline.access')
.setParam('response_type', 'code')
.setParam('code_challenge_method', 'S256')
.setParam('code_challenge', userProps.getProperty("code_challenge"))
.setTokenHeaders({
'Authorization': 'Basic ' + Utilities.base64Encode(CLIENT_ID + ':' + CLIENT_SECRET),
'Content-Type': 'application/x-www-form-urlencoded'
})
}
function authCallback(request) {
const service = getService();
const authorized = service.handleCallback(request);
if (authorized) {
return HtmlService.createHtmlOutput('Success!');
} else {
return HtmlService.createHtmlOutput('Denied.');
}
}
function pkceChallengeVerifier() {
var userProps = PropertiesService.getUserProperties();
if (!userProps.getProperty("code_verifier")) {
var verifier = "";
var possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
for (var i = 0; i < 128; i++) {
verifier += possible.charAt(Math.floor(Math.random() * possible.length));
}
var sha256Hash = Utilities.computeDigest(Utilities.DigestAlgorithm.SHA_256, verifier)
var challenge = Utilities.base64Encode(sha256Hash)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
userProps.setProperty("code_verifier", verifier)
userProps.setProperty("code_challenge", challenge)
}
}
function logRedirectUri() {
var service = getService();
Logger.log(service.getRedirectUri());
}
function main() {
const service = getService();
if (service.hasAccess()) {
Logger.log("Already authorized");
} else {
const authorizationUrl = service.getAuthorizationUrl();
Logger.log('Open the following URL and re-run the script: %s', authorizationUrl);
}
}
function sendTweet() {
var tweetData = pickUpTweet("twitter_text"); // ツイートの内容を取得
var payload = { text: tweetData };
console.log(payload);
var service = getService();
if (service.hasAccess()) {
var url = `https://api.twitter.com/2/tweets`;
var response = UrlFetchApp.fetch(url, {
method: 'POST',
'contentType': 'application/json',
headers: {
Authorization: 'Bearer ' + service.getAccessToken()
},
muteHttpExceptions: true,
payload: JSON.stringify(payload)
});
var result = JSON.parse(response.getContentText());
Logger.log(JSON.stringify(result, null, 2));
} else {
var authorizationUrl = service.getAuthorizationUrl();
Logger.log('Open the following URL and re-run the script: %s',authorizationUrl);
}
}
function pickUpTweet(sheetName) {
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(sheetName);
const lastRow = sheet.getLastRow();
const data = sheet.getRange("A1:F" + lastRow).getValues();
const availableTweets = data.filter(function(value) {
return value[1] == '';
});
if (availableTweets.length == 0) return { text: '', imageUrl: '' };
const weights = availableTweets.map(function(value) {
return value[4]; // Assuming the weight is in column E
});
const alias = new AliasMethod(weights);
const selectedIndex = alias.next();
const selectedRow = availableTweets[selectedIndex][0];
Logger.log("Selected row: " + selectedRow); // Log the selected row number
sheet.getRange(selectedRow + 1, 2).setValue(new Date());
return availableTweets[selectedIndex][2];
}
class AliasMethod {
constructor(weights) {
this.prob = [];
this.alias = [];
this.n = weights.length;
const small = [];
const large = [];
const scaledWeights = weights.map((w) => w * this.n);
for (let i = 0; i < this.n; i++) {
if (scaledWeights[i] < 1) {
small.push(i);
} else {
large.push(i);
}
}
while (small.length > 0 && large.length > 0) {
const l = small.pop();
const g = large.pop();
this.prob[l] = scaledWeights[l];
this.alias[l] = g;
scaledWeights[g] = scaledWeights[g] + scaledWeights[l] - 1;
if (scaledWeights[g] < 1) {
small.push(g);
} else {
large.push(g);
}
}
while (large.length > 0) {
const g = large.pop();
this.prob[g] = 1;
}
while (small.length > 0) {
const l = small.pop();
this.prob[l] = 1;
}
}
next() {
const i = Math.floor(Math.random() * this.n);
return Math.random() < this.prob[i] ? i : this.alias[i];
}
}
このコードを実行すると、実行ログで下記のように表示され、無事tweetされた。
また、スプレッドシートを見ると、B列の投稿日時に投稿した日付が挿入されている。
テキスト投稿アルゴリズム解説
GASスクリプトのアルゴリズムを簡単に解説
- C列の投稿内容に記載されている内容のうち、B列(投稿日時)に日付が入っていない投稿内容のうち、D列(重み)の値が高いものを優先的に抽出して投稿する。
- twitterに投稿されると、その時点でB列に投稿日時が表示され、今後はB列の日付を削除しない限り投稿されない(過去に投稿した内容の重複投稿を防ぐ)
- D列(重み)に入力する数値は整数であれば何でもOK。
優先投稿に関するGASのタイムアウトエラー対策
GASは一度の実行時間は最大6分までで、6分を超えるとタイムアウトエラーになってしまう。なので、重みづけをして投稿内容に応じて投稿頻度優先度をつけたい場合、スプレッドシート上の数千ものテキストから1つを抽出する場合、通常の重みづけアルゴリズム(累積和)だとタイムアウトエラーになってしまう。
今回は、上記対策で、GASスクリプトのAliasMethodクラスでWalker’s Alias Methodを実行している。この場合、準備のテーブル構築にO(n)、投稿テキストのランダム選択にO(1)でできる。ちなみに、3000行のテキストで実行した場合でもタイムアウトエラーは起きなかった。
Walker’s Alias Methodに関して詳しく知りたい方は、下記記事が分かりやすかったのでご参照ください。
https://qiita.com/kaityo256/items/1656597198cbfeb7328c
重み付きサンプリングアルゴリズムは、主に累積和、二分木、Walker’s Aliasがあるが、それぞれでテーブル構築、探索、部分更新の手間は下記。今回は、サンプリングするたびに、毎回重みが変わることはないため、テキストを抽出する行が数千などと増える場合にはWalker’s Aliasが適していると考えられる。
テーブル構築 | 探索 | 部分更新 | |
累積和 | O(N) | O(log N) | O(N) |
Walker’s Alias | O(N) | O(1) | O(N) |
二分木 | O(N) | O(log N) | O(log N) |
各アルゴリズム比較の詳しい記事はこちら
https://qiita.com/kaityo256/items/64c12bb8c8946d7f03c6
投稿可能内容:テキスト・リンク・タグ・twitterアカウント・過去の投稿画像
このGASスクリプトで投稿可能な項目は
・テキスト
・リンク
・タグ
・twitterアカウントメンション
・過去のtwitter投稿画像
の主に5つ。
例えば、下記のように、リンク、タグ、twitterアカウント(今回は@tesu35416661269)を投稿内容の欄に記載して、GASを実行するとこのようにtweetされる。twitterアカウント名の前後には空白必要ない。
画像投稿
今回は、画像投稿に関して下記2パターンを紹介する。
・Twitterで過去に投稿した画像を再投稿
・web上に公開した画像(publicにアクセスできる画像)を投稿
twitterで過去に投稿した画像を、botに組み込んで再投稿したい場合。
画像付き投稿の右上にある3点リーダーマークをクリックし、「ポストを埋め込む」をクリック。ただし、公開アカウントにしていないと(鍵を外していないと)「ポストを埋め込む」は表示されない。
すると、新しいタブが開いて下記画面に遷移するので「Copy Code」をクリック
コピーしたコードの中身は下記のような感じ
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">サーボモーター比較 <a href="https://t.co/vc3RfQmtsC">pic.twitter.com/vc3RfQmtsC</a></p>— てす (@tesu35416661269) <a href="https://twitter.com/tesu35416661269/status/1703560901708890465?ref_src=twsrc%5Etfw">September 18, 2023</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>
pic.twitter.comから始まるリンクが、画像情報が含まれている部分。今回の場合は、pic.twitter.com/vc3RfQmtsC で、実際に開いてみると、twitter投稿画像が表示されていることがわかる。リンク先の内容はテキストも含まれているが、スプレッドシートに記載してbot投稿すると画像だけの投稿になる。
このリンクを通常のリンクの場合と同様に、スプレッドシートの投稿内容の部分に記載して、GASを実行すると、無事画像が投稿された。
Web上に公開した画像を投稿
スプレッドシートのE列に「image_url」という名前のカラムを用意し、publicにアクセスできる画像URLを記載する。
今回は、下記の画像URLでテスト
https://kazulog.fun/wp-content/uploads/2023/08/dc-servo.jpg
と思ったのだが、現状、Twitter API v2は画像アップロード機能を搭載していなくてTwitter API ver1.1を使う必要があり、かつ、OAuth2はAPI v2しかサポートしておらず、新規のOauth1ライブラリも導入しないといけないなど、かなりややこしい。こちらの記事で記載。
コメント