【無料】X(Twitter)自動ポストツールを作ってみたけれど
不安です。
…というのは
20年前のJAVAしか知らないのにGASに手を出し
偉大な先輩方の知見切り貼りで
1日でなんとか動かしてしまったからなのです。
(ダメだったら即消します)
できること
Googleドライブ上で動作します
「上から順にツイート」か「ランダムにツイート」ができます
Twitter API v1で画像を投稿し、v2でツイート(ポスト)します
画像1ツイート4枚まで、Googleドライブ上からひっぱる想定です
(アクセス許可を「リンクを知っている全員」にした上でリンクをコピー→複数枚の場合カンマ区切りでセルに格納)
※パブリックな画像ならほかの場所でも行ける気がするGASのトリガーでDoTweet関数をたたくことで(大雑把な)時間指定投稿ができます
ハッシュタグ入り、URL入り、改行入りのツイートもOKでした
予約投稿、定期投稿、リプライなどの機能は今のところ考えてません
未検証
画像1枚は検証済だが複数枚がまだ
もろもろのエラーが正しく吐かれるのか不明
いらん項目やら小汚いコードやらだらけな気がするのですが
閲覧権限で置いておくので複製保存してお使いください。
ソースコード
function isValidRow(row, start, end) {
for (let i = start; i <= end; i++) {
if (row[i] === "") {
return false;
}
}
return true;
}
function putErrorMsg(message) {
console.log(message);
Browser.msgBox(message);
}
function isCompleteMatchRow(row, index, key) {
const str = row[index].toString().replace(/ /g, "").trim().toLowerCase();
const keyStr = key.toString().replace(/ /g, "").trim().toLowerCase();
return str === keyStr;
}
const accessTokenURL = 'https://api.twitter.com/oauth/access_token';
const requestTokenURL = 'https://api.twitter.com/oauth/request_token';
const authorizationURL = 'https://api.twitter.com/oauth/authorize';
const serviceName = 'twitter';
class SettingSheetForTwitterBot {
constructor(ss) {
this.sheet = ss.getSheetByName('設定');
this.apiKey = this.sheet?.getRange('B2').getValue();
this.apiSecret = this.sheet?.getRange('B3').getValue();
this.ACCESS_TOKEN = this.sheet?.getRange('B4').getValue();
this.ACCESS_TOKEN_SECRET = this.sheet?.getRange('B5').getValue();
this.CLIENT_ID = this.sheet?.getRange('B6').getValue();
this.CLIENT_SECRET = this.sheet?.getRange('B7').getValue();
this.BEARER_TOKEN = this.sheet?.getRange('B8').getValue();
this.ACCOUNT_ID = this.sheet?.getRange('B9').getValue();
this.tweetType = this.sheet?.getRange('B11').getValue();
}
}
class TweetList {
constructor(ss) {
this.sheet = ss.getSheetByName('tweetリスト');
this.parentData = this.sheet?.getRange(2, 1, this.sheet.getLastRow(), this.sheet.getLastColumn()).getValues();
}
getRandomTweetText() {
let validData = this.parentData?.slice().filter(row => isValidRow(row, 0, 0));
if (!validData || validData.length === 0) {
return null;
}
// 過去にツイートしたことのないものを優先
let priorityData = validData.filter(row => isCompleteMatchRow(row, 1, ''));
if (priorityData.length > 0) {
const max = priorityData.length;
const index = Math.floor(Math.random() * max);
const tweetText = priorityData[index][0];
return tweetText;
} else {
validData = validData.sort(function (a, b) {
return a[1] - b[1];
});
const tweetText = validData[0][0];
return tweetText;
}
}
getTweetText() {
let validData = this.parentData?.slice().filter(row => isValidRow(row, 0, 0));
if (!validData || validData.length === 0) {
return null;
}
// 過去にツイートしたことのないものを優先
let priorityData = validData.filter(row => isCompleteMatchRow(row, 1, ''));
if (priorityData.length > 0) {
const tweetText = priorityData[0][0];
return tweetText;
} else {
validData = validData.sort(function (a, b) {
return a[1] - b[1];
});
const tweetText = validData[0][0];
return tweetText;
}
}
getMatchRow(value, colIndex) {
if (!this.parentData) {
return null;
}
for (let i = 0; i < this.parentData.length; i++) {
const rowValue = this.parentData[i][colIndex];
if (rowValue === value) {
return i + 2;
}
}
return null;
}
getImageUrls(tweetText) {
if (!this.parentData) {
return [];
}
for (let i = 0; i < this.parentData.length; i++) {
if (this.parentData[i][0] === tweetText) {
const imageUrl = this.parentData[i][3]; // D列はインデックス3
if (imageUrl) {
// 画像URLをカンマ区切りで複数指定可能と仮定
return imageUrl.split(',').map(url => url.trim()).filter(url => url !== '');
}
break;
}
}
return [];
}
}
// OAuth1.0のトークンを生成し、認証画面へのリンクを表示する関数
function authorizeLink1() {
const service = getService1();
if (service === null) {
return;
}
const ui = SpreadsheetApp.getUi();
if (!service.hasAccess()) {
const authorizationURL = service.authorize();
const html = HtmlService.createTemplate('<a href="<?= authorizationURL ?>" target="_blank">アカウント認証ページ</a>');
html.authorizationURL = authorizationURL;
const output = html.evaluate();
ui.showModalDialog(output, 'Twitterアカウント認証');
} else {
ui.alert("アカウント認証はすでに許可されています。");
}
}
// OAuth1.0の認証で、Twitterにアクセスする関数
function getService1() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const setting = new SettingSheetForTwitterBot(ss);
if (setting.apiKey === "" || setting.apiSecret === "") {
putErrorMsg(`設定シートに記載されているAPI_KEYまたはAPI_SECRETが正しく設定されていません。`);
return null;
}
return OAuth1.createService(serviceName)
.setAccessTokenUrl(accessTokenURL)
.setRequestTokenUrl(requestTokenURL)
.setAuthorizationUrl(authorizationURL)
.setConsumerKey(setting.apiKey)
.setConsumerSecret(setting.apiSecret)
.setCallbackFunction('authCallback1')
.setPropertyStore(PropertiesService.getUserProperties());
}
// 認証の確認後に表示するメッセージ画面を操作する関数
function authCallback1(request) {
const service = getService1();
if (service === null) {
return;
}
const isAuthorized = service.handleCallback(request);
if (isAuthorized) {
return HtmlService.createHtmlOutput('認証が許可されました。');
} else {
return HtmlService.createHtmlOutput('認証が拒否されました。再度ご確認の上、お試しください');
}
}
// OAuth2.0の認証処理実行
function authorizeLink2() {
const service = getService2();
if (service === null) {
return;
}
const ui = SpreadsheetApp.getUi();
if (!service.hasAccess()) {
const authorizationURL = service.getAuthorizationUrl();
const html = HtmlService.createTemplate('<a href="<?= authorizationURL ?>" target="_blank">アカウント認証ページ</a>');
html.authorizationURL = authorizationURL;
const output = html.evaluate();
ui.showModalDialog(output, 'Twitterアカウント認証');
} else {
ui.alert("アカウント認証はすでに許可されています。");
}
}
// OAuth2.0の認証
function getService2() {
pkceChallengeVerifier();
const userProps = PropertiesService.getUserProperties();
const serviceName = 'twitter';
const ss = SpreadsheetApp.getActiveSpreadsheet();
const setting = new SettingSheetForTwitterBot(ss);
if (setting.CLIENT_ID === "" || setting.CLIENT_SECRET === "") {
putErrorMsg(`設定シートに記載されているAPI_KEYまたはAPI_SECRETが正しく設定されていません。`);
return null;
}
return OAuth2.createService(serviceName)
.setAuthorizationBaseUrl('https://twitter.com/i/oauth2/authorize')
.setTokenUrl('https://api.twitter.com/2/oauth2/token?code_verifier=' + userProps.getProperty("code_verifier"))
.setClientId(setting.CLIENT_ID)
.setClientSecret(setting.CLIENT_SECRET)
.setCallbackFunction('authCallback2')
.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(setting.CLIENT_ID + ':' + setting.CLIENT_SECRET),
'Content-Type': 'application/x-www-form-urlencoded'
});
}
function authCallback2(request) {
const service2 = getService2();
const isAuthorized = service2.handleCallback(request);
if (isAuthorized) {
return HtmlService.createHtmlOutput('OAuth2.0の認証に成功しました!');
} else {
return HtmlService.createHtmlOutput('OAuth2.0の認証に失敗しました。認証情報の内容をご確認ください。');
}
}
function pkceChallengeVerifier() {
let userProps = PropertiesService.getUserProperties();
if (!userProps.getProperty("code_verifier")) {
let verifier = "";
let possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
for (let i = 0; i < 128; i++) {
verifier += possible.charAt(Math.floor(Math.random() * possible.length));
}
let sha256Hash = Utilities.computeDigest(Utilities.DigestAlgorithm.SHA_256, verifier)
let challenge = Utilities.base64Encode(sha256Hash)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
userProps.setProperty("code_verifier", verifier)
userProps.setProperty("code_challenge", challenge)
}
}
// ダイアログ表示
function showDialog(title, text) {
const html = createDialogHtml(text)
SpreadsheetApp.getUi().showModalDialog(html, `${title}`);
}
// ダイアログ内のHTML生成
function createDialogHtml(text) {
const html = HtmlService.createTemplateFromFile('dialog');
html.text = `${text}`;
return html.evaluate();
}
// スプレッドシート内のデータを取得してツイートする関数(ランダム配信/上から順に配信)
function DoTweet() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const setting = new SettingSheetForTwitterBot(ss);
const list = new TweetList(ss);
let tweetText;
switch (setting.tweetType) {
case "ランダム配信":
tweetText = list.getRandomTweetText();
break;
case "上から順に配信":
tweetText = list.getTweetText();
break;
default:
return putErrorMsg('配信方式が正しく選択されていません');
}
if (!tweetText) {
return putErrorMsg('ツイート生成に失敗しました');
}
// 画像URLの取得
const imageUrls = list.getImageUrls(tweetText);
let mediaIds = [];
let file;
if (imageUrls.length > 0) {
if (imageUrls.length > 4) {
return putErrorMsg('1ツイートに添付できる画像は最大4枚です。');
}
for (let i = 0; i < imageUrls.length; i++) {
const imageUrl = imageUrls[i];
try {
// GoogleドライブのURLからファイルIDを取得
const fileId = getGoogleDriveFileId(imageUrl);
file = DriveApp.getFileById(fileId);
} catch (e) {
return putErrorMsg(`画像の取得に失敗しました。URL: ${imageUrl} エラー: ${e}`);
}
// 画像形式の検証
const mimeType = file.getMimeType();
if (mimeType !== MimeType.JPEG && mimeType !== MimeType.PNG) {
return putErrorMsg(`許可されていない画像形式です。URL: ${imageUrl}`);
}
try {
const mediaId = uploadMediaToTwitter(file.getBlob());
mediaIds.push(mediaId);
} catch (e) {
return putErrorMsg(`画像のアップロードに失敗しました。URL: ${imageUrl} エラー: ${e}`);
}
}
}
// 画像をTwitterにアップロードし、media_idを取得する関数
function uploadMediaToTwitter(file) {
const service1 = getService1();
if (service1 === null) {
throw new Error('認証が必要です。');
}
const url = "https://upload.twitter.com/1.1/media/upload.json";
const mediaData = Utilities.base64Encode(file.getBytes());
const payload = {
'media_data': mediaData
};
const options = {
'method': 'post',
'payload': payload,
'muteHttpExceptions': true
};
const response = service1.fetch(url, options);
const resCode = response.getResponseCode();
const result = JSON.parse(response.getContentText());
if (resCode === 200) {
return result.media_id_string;
} else {
throw new Error(`メディアアップロードに失敗しました。詳細:${response.getContentText()}`);
}
}
// ツイートするAPIリクエスト
const url = "https://api.twitter.com/2/tweets";
const service2 = getService2();
let payload;
if (mediaIds.length > 0) {
payload = JSON.stringify({
'text': tweetText,
'media': {
'media_ids': mediaIds
}
});
} else {
payload = JSON.stringify({
'text': tweetText
});
}
const options = {
'method': 'post',
headers: {
Authorization: 'Bearer ' + service2.getAccessToken(),
'Content-Type': 'application/json',
},
'payload': payload,
'contentType': 'application/json',
'muteHttpExceptions': true
};
let isSuccess;
try {
isSuccess = makeRequest(url, options);
} catch (e) {
return putErrorMsg(`ツイートに失敗しました。エラー内容:${e}`);
}
if (isSuccess === false) {
return;
}
const insertRow = list.getMatchRow(tweetText, 0);
if (insertRow === null || insertRow < 2) {
return putErrorMsg(`tweetリストシートの更新に失敗しました。`);
}
const todayStr = Utilities.formatDate(new Date(), 'JST', 'yyyy/MM/dd');
list.sheet?.getRange(insertRow, 2).setValue(todayStr);
}
// GoogleドライブのURLからファイルIDを抽出する関数
function getGoogleDriveFileId(url) {
const regex = /\/d\/([a-zA-Z0-9_-]+)/;
const match = url.match(regex);
if (match && match[1]) {
return match[1];
} else {
throw new Error('無効なGoogleドライブのURLです。');
}
}
// API リクエストを送信するための関数
function makeRequest(url, options) {
const service1 = getService1();
const res = service1.fetch(url, options);
const resCode = res.getResponseCode();
const result = JSON.parse(res.getContentText());
const resultBodyText = JSON.stringify(result);
console.log(resCode);
console.log(resultBodyText);
// Twitter API v2の成功レスポンスコードは200または201
if (resCode !== 201 && resCode !== 200) {
putErrorMsg(`ツイートに失敗しました。詳細:${resultBodyText}`);
return false;
}
return true;
}
// プロパティストアに保存したトークンをリセットする関数
function clearService() {
OAuth1.createService(serviceName).setPropertyStore(PropertiesService.getUserProperties()).reset();
}
function getScriptIDForTwitterBot() {
const scriptID = ScriptApp.getScriptId();
Browser.msgBox("スクリプトID:" + scriptID);
}
/**
* menu
*/
function onOpen() {
const ui = SpreadsheetApp.getUi();
const menu = ui.createMenu("メニュー");
menu.addItem('アカウント認証1.0', 'authorizeLink1');
menu.addItem('アカウント認証2.0', 'authorizeLink2');
menu.addItem('スクリプトID表示', 'getScriptIDForTwitterBot');
menu.addItem('ツイート配信', 'DoTweet');
menu.addItem('アカウント認証クリア', 'clearService');
menu.addToUi();
}
この記事が気に入ったらサポートをしてみませんか?