🤖

【無料】Twitter(X)のポストをBlueskyに自動投稿できるプログラムを作ったよ。[spreadsheet/GAS経由]

2024/11/24に公開


~Twitterの名前がXに変更されてはや数億年。あの鳥はどこへ旅立ってしまったのだろうか。 
ふと空を見上げてみる。青い空が広がっていた。世の中は変わりまくっているのに、あの青い空だけは変わらない。~

Xのポストを取得するためには、APIという謎のものを使う。そしてBlueskyにポストするためには、また別のAPIを使う。このAPIからAPIまでの道中は簡単なものではなく、google spread sheetのGASでどうにか自動化されている。以下に脱Xの方法を示そう。(いや結局Xでポストを投稿し続けることになるから、Xに主軸をおいて活動していく人向けにはなる…)

あと、このプログラムを使用するメリットやデメリットは下の方に記載しておくよ。メリットもデメリットもそこそこあるで。目次から飛べるので心配な人はどうぞ。はっきり言います。実用性皆無かもしれないwww

(i)方法

①Xの無料APIに申請する

私が説明するより、勝手にそこら辺のブログを見たほうが速い!

https://sananeblog.com/twitter-x-api-generate/

無料APIは、大幅な制限がある。しかし、個人が使用する分には申し分ない(のかは怪しいかもしれない)。 申請が終わったら、https://developer.x.com/en/portal/projects/ から、メニューにある「PROJECT&APP」をクリック。「Keys and tokens」に飛んで、「Bearer Token」を生成して、コピーする。コピーしたTokenは大切なとこにしまってね。(つまり、メモしてください)

ここからさき、メモを何連続か行うよ。ブラウザのようなメモ帳は避けて、スプレッドシートの中とか、パソコンのメモ帳とかにメモしておきましょう。もちろん、メモしたやつが何だったのかを忘れないように。

あと、あなたが取得したいアカウントのユーザーIDもコピーしてね。

②BlueskyのApp passwordを作成する

blueskyに登録したら、ユーザー名を丸ごとコピーして大切にしまっておいてください。 設定(歯車マーク)→プライバシーとセキュリティ→App password(日本語名は知らん)を選択し、なんかいい感じのボタンを押してアプリのパスワードを追加してください。名前は適当でOK。追加出来たら、出てきたコードをコピーしてください。

アプリのパスワードができたら、コピーして大切にしまっておこう。

③スプレッドシートをカチャカチャする

Google driveを開いて、どこでもいいのでスプレッドシートを作ってください。作り方がわからなければ、そこらへんのサイトが参考になるはず…!

作りましたら、上のメニューの拡張機能をクリックして、出てきたApp Scriptを押してください。


参考図
まだ何も入力してない状態でこの作業を行ってください。
なお、スプレッドシートのファイル名とかは自由に設定OK。

出てきた画面は、後で使うで。

話は戻って、スプレッドシートのリンクのうち、下の図の黄色いマーカーの部分はコピーしておいてください。もちろんだけど、赤い線に隠れてる部分も含めてで。

あと、(PCなら下の方にあるはずの)シート名もコピーしておきましょう。図ではXPostsになってるけど、みなさんの作りたてのスプレッドシートではシート1となってるはず。まあ、どちらでもいいのでとにかくコピーしちゃいましょう。

やったー、これで下準備は完了!

④コードを貼り付ける

③で開いたAppscriptを見て。function myfunction ってないかい? はい、それは全消ししてね。代わりに、以下のコードを貼って!☆

var SHEET_NAME = '!!!!シート名入力!!!!';
var BEARER_TOKEN = '!!!!XのTOKEN入力!!!!';
var BLUESKY_USERNAME = '!!!!BlueskyのユーザーIDはここ、*******.bsky.socialってなるはず!!!!';
var BLUESKY_APP_PASSWORD = '!!!!Blueskyのアプリパスワードはここ、****-****-****-****ってなるはず!!!!';
var SPREADSHEET_ID = '!!!!③でリンクからコピーした長い文字列はここ!!!!'; // GoogleスプレッドシートのID

var LAST_POST_ROW_KEY = 'lastPostRow'; // 最後に投稿した行を保存するキー

/**
 * 自分のアカウントの最新ツイートを取得し、更新があればスプレッドシートに保存
 */
function fetchMyTweetsAndSave() {

  try {
    const username = '!!!!あなたのXユーザーIDはここ、「@」はいらないよ!!!!'; // あなたのTwitterユーザー名
    const maxResults = 10; // 最大取得数(10~100の範囲)これは、一回のAPIによる取得当たりどのくらいポストがとれるかどうかの数字。最大で100まで設定可能なはず...多分...もしかしたら50とか35とかかも...わからん...

    const url = `https://api.twitter.com/2/tweets/search/recent?query=from:${username}&max_results=${maxResults}&tweet.fields=attachments,entities`;

    const options = {
      method: 'get',
      headers: {
        Authorization: `Bearer ${BEARER_TOKEN}`,
      },
      muteHttpExceptions: true, // エラー内容を取得
    };

    const response = UrlFetchApp.fetch(url, options);
    const responseCode = response.getResponseCode();

    if (responseCode !== 200) {
      throw new Error(`Twitter API request failed with status ${responseCode}: ${response.getContentText()}`);
    }

    const tweets = JSON.parse(response.getContentText()).data;

    if (!tweets || tweets.length === 0) {
      Logger.log('新しいツイートはありません。');
      return;
    }

      const lastTweetId = getLastTweetIdFromSheet();
      const newTweets = tweets.filter((tweet) => tweet.id > lastTweetId);

    if (newTweets.length === 0) {
      Logger.log('既に最新の状態です。');
      return;
    }

    saveTweetsToSheet(newTweets);
  } catch (error) {
    Logger.log(`エラーが発生しました: ${error.message}`);
  }
}

/**
 * スプレッドシートの最新のツイートIDを取得
 */
function getLastTweetIdFromSheet() {
  const sheet = SpreadsheetApp.openById(SPREADSHEET_ID).getActiveSheet();
  const lastRow = sheet.getLastRow();

  if (lastRow < 2) {
    // ヘッダーしかない場合
    return '0';
  }

  return sheet.getRange(lastRow, 1).getValue(); // A列の最新ツイートIDを取得
}

/**
 * 新しいツイートをスプレッドシートに保存
 * @param {Array} tweets ツイートの配列
 */
function saveTweetsToSheet(tweets) {
  const sheet = SpreadsheetApp.openById(SPREADSHEET_ID).getActiveSheet();

  if (sheet.getLastRow() === 0) {
    // ヘッダーを設定
    sheet.appendRow(['ID', '投稿日', '内容', '画像']);
  }

  tweets.reverse().forEach((tweet) => {
    const tweetText = tweet.text;
    const tweetId = tweet.id;
    const tweetCreatedAt = tweet.created_at;

    // ツイート内の画像URLを取得
    let imageUrl = '';
    if (tweet.entities && tweet.entities.media) {
      // メディア情報が存在する場合、画像URLを取得
      imageUrl = tweet.entities.media[0].media_url_https; // 最初の画像URLを取得
    }

    // ツイートデータをスプレッドシートに追加(画像URLを表示する)
    sheet.appendRow([tweetId, tweetCreatedAt, tweetText, `=IMAGE("${imageUrl}")`]);
  });

  Logger.log(`${tweets.length}件の新しいツイートを保存しました。`);
  Logger.log('スプレッドシート保存完了');
  main()
}

// メイン処理:新しい投稿をBlueskyに送信
function main() {
  const newPosts = getNewPosts();  // 新しい投稿を取得
  Logger.log(`取得した新しい投稿: ${newPosts}`);
  
  if (newPosts.length === 0) {
    Logger.log("新しい投稿はありません。");
    return;  // 新しい投稿がない場合は終了
  }

  const session = createSession(); // セッションを作成して保存
  if (session) {
    newPosts.forEach((msg) => {
      createRecord(msg, session); // 新しいメッセージを投稿する
    });

    // 最後に投稿した行番号を保存
    const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(SHEET_NAME);
    const lastRow = sheet.getLastRow();
    saveLastPostRow(lastRow); // ここで最新の行番号を保存
  } else {
    Logger.log("セッション作成に失敗しました。");
  }
}

// 最後に投稿した行番号を取得(初回の場合は1行目)
function getLastPostRow() {
  const scriptProperties = PropertiesService.getScriptProperties();
  const lastRow = scriptProperties.getProperty(LAST_POST_ROW_KEY);
  return lastRow ? parseInt(lastRow) : 0; // 初回の場合は0
}

// 新しい投稿を取得(最後の投稿行番号から次の新しい投稿を取得)
function getNewPosts() {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(SHEET_NAME);
  const lastRow = sheet.getLastRow();

  if (lastRow < 2) {
    Logger.log("データがありません。");
    return [];
  }

  // 前回投稿された行番号を取得(設定されていない場合は2行目から取得)
  const lastPostRow = PropertiesService.getScriptProperties().getProperty('lastPostRow');
  const startRow = lastPostRow ? parseInt(lastPostRow) + 1 : 2; // 最初の投稿は2行目からスタート

  // 範囲のチェック
  if (startRow > lastRow) {
    Logger.log(`新しい投稿はありません。 startRow: ${startRow}, lastRow: ${lastRow}`);
    return [];
  }

  // C列のデータを取得(新しいデータのみ)
  const newPosts = sheet.getRange(startRow, 3, lastRow - startRow + 1, 1).getValues();
  Logger.log(`新しい投稿を取得しました: startRow=${startRow}, lastRow=${lastRow}, newPosts=${newPosts}`);
  return newPosts.flat(); // 配列を平坦化して返す
}

// 最後に投稿した行番号を保存
function saveLastPostRow(row) {
  const scriptProperties = PropertiesService.getScriptProperties();
  scriptProperties.setProperty('lastPostRow', row.toString());
  Logger.log(`最後の投稿行を保存しました: ${row}`);
}

// セッションを作成
function createSession() {
  const url = 'https://bsky.social/xrpc/com.atproto.server.createSession';

  const payload = {
    identifier: BLUESKY_USERNAME,  // アカウントID
    password: BLUESKY_APP_PASSWORD,  // アプリパスワード
  };

  const options = {
    method: 'post',
    headers: {
      'Content-Type': 'application/json; charset=UTF-8',
    },
    payload: JSON.stringify(payload),
    muteHttpExceptions: true // HTTPエラーをミュート
  };

  try {
    const response = UrlFetchApp.fetch(url, options);
    return JSON.parse(response.getContentText());
  } catch (error) {
    console.error('Error creating session:', error);
    return null;
  }
}

// Blueskyに投稿する
function createRecord(msg, session) {
  if (!session || !session.handle || !session.accessJwt) {
    console.error('Invalid session');
    return;
  }

  const url = 'https://bsky.social/xrpc/com.atproto.repo.createRecord';

  const payload = {
    repo: session.handle, // セッションからハンドルを取得
    collection: 'app.bsky.feed.post',
    record: {
      text: msg,
      createdAt: new Date().toISOString(),
    },
  };

  const options = {
    method: 'post',
    headers: {
      'Content-Type': 'application/json',
      Authorization: 'Bearer ' + session.accessJwt, // セッションからアクセストークンを取得
    },
    payload: JSON.stringify(payload),
    muteHttpExceptions: true // HTTPエラーをミュート
  };

  try {
    const response = UrlFetchApp.fetch(url, options);
    console.log('Record created:', response.getContentText());
  } catch (error) {
    console.error('Error creating record:', error.message);
  }
}

//debug
function resetLastPostRow() {
  PropertiesService.getScriptProperties().deleteProperty('lastPostRow');
  Logger.log('lastPostRow が削除されました');
}

!!!!ってなってるところは、先ほどあなたがコピーして大切にしまっておいたものを貼り付けるところだよ。
完成形は、
var SHEET_NAME = 'シート1';
みたいになっているはず。

**一回実行してください。上の再生ボタン?を押せば、勝手に実行されるよ。何のエラーも起きなければ、大丈夫。blueskyのアカウントに移って、自分のポストを確認してみよう。**ただし、最初の実行時は、勝手に約10件のポストが取得されて、勝手にポストされるよ。ご注意を。

※最初の実行時に認証が求められるけど、あなたのプロジェクトなので、勝手に認証しておいてね。詳細ボタンをクリックすれば認証できるはず。

具体的なコードの説明はあとでね。

⑤トリガーを作る

トリガーとは簡単に、プログラムを動かすやつのこと。トリガーを引けば、プログラムが動くよ。ここでは、トリガーが一定時間で動くものを作っていくで。

App Scriptの左側にあるアラームにご覧あれ。それがトリガー。押してみて。

右下のトリガーを追加を押してね。

この図のように、左上から一つずつ順に設定を進めて。そうすれば、多少足りない項目があったとしても、最終的にこの図の通りになるはず。

(追記:API制限が思ったより強いので、ひょっとしたら1週間に1回とかのほうが良いかもしれません…)

以上で、設定は終了!お疲れさまでした!!!🍕🍕🍕

(ii)コードの解説等

実はこのコード、ChatGPTに書かせたものなの。すごくない? 厳密にはChatGPTに書かせたものを私が修正・デバッグして、足りない項目はさらにChatGPTに書かせて…というのを3時間くらいかけてやったらできた!!やったね!!!!

原理は簡単。先ほどもお伝えしたように、 XのポストをAPIで取得→スプレッドシートに掲載→スプレッドシートの更新分をAPIを用いてBlueskyに投稿 というシンプル?な作り。

一番苦労したのは、blueskyへの投稿。chatGPTが対応してなかったので、丁寧に教えていきながら作らせたよ。 スプレッドシートが更新してないのにも関わらずBlueskyへの投稿が続くといったプログラムミスも起きたり…

Xの無料APIは1月100回までしかポストを取得できないので、8時間に1回の劇遅ポスト取得となっているよ。仕方ないね((( XのAPIをもう一つ申請したり、別アカウントでXのAPIを申請したり…とかもできそうかなっておもったけど、できなかった。まあ、そういう日もあるよね。

こんなのは詐欺だ!ぼったくりだ!!今すぐ返品してやる!!!と思うかもだけど、そういう人のための逆のバージョンも用意されてあるよ。ネット上でたまたま見つけたけどね。試したことないけど、良ければぜひ♡

https://zenn.dev/henteko/articles/f13f1c9b43b94d

コードの説明はコードに書いてあるものが大半。最初にfetchmytweetsAndSaveを実行し、真っ先にmainを連鎖実行させる。これは、前回のスプレッドシートの更新でのとり逃しを防ぐため。要するに、スプレッドシートは更新したのに、blueskyの更新がなされていなかったケースの保険。そんなケース、滅多に起きないかもしれないけどね。 main関数の実行時に、新しい投稿はありません。と表示されるけど、これは、"スプレッドシートに"新しい投稿はない、っていうことだよ。勘違いしないでね。 つづけてmaxResults分のツイートを取得するよ。ここに貼ってあるコードなら、過去10回分。自由に変更可能。 新しいツイートが見つかれば、下の方の実行ログに表示されるで。もちろん、見つからなくても、それに該当するメッセージが返ってくる。

saveTweetsToSheetの実行が中盤で起きるが、ここでは、スプレッドシートに保存するという作業をする。新しいツイートがあれば、「??件の新しいツイートを保存しました。」と表示されるよ。そして、main関数にバトンタッチ。

main関数では、blueskyに新しい投稿を送信するプログラムが付いてるよ。詳しいことは省くけど、C列に溜まっていた、Xの投稿内容を引っ張り出してきて、recordというのを作って投稿するよ。 ここが苦労したところなんだけど、新しい行isどこ?ってApp Scriptくんがならないように、新しい行がどれなのかを保存させれるようにしたで。それが、 startRow: ${startRow}, lastRow: ${lastRow}`のとこだね。

以上かな。最後のdebug部分(resetLastPostRow関数)は新しい投稿があるのに、新しい投稿がない判定になってる!とか、新しい投稿がないのに、新しい投稿がある判定になってる!っていうときに使うよ。 上の、fetchMtTweetsAndSaveのとこをクリックして、resetLastPostRowに変更して実行してみてね。実行した後はfetchMtTweetsAndSaveに戻してね。

(iii)メリット・デメリット・注意点

メリット

・完全無料。
・オープンソース(???)なのが強み。
・設定した後は、勝手に放置していつも通りの青い鳥ライフをすればよい。(Blueskyに主軸をおいている人除く)

デメリット

・イーロンが急に変えてくる場合がある。
・画像の取得ができない。
・ツイートの取得可能件数、取得するツイートの数に依存してる???
(現在調査中、もし本当なら1か月の間に100件しか取得できないという実用性皆無なbotになる)

将来性

イーロンがなんかしてこなければ、画像の実装機能も考えるかも。
 ていうか、そもそも!!!コードがむずすぎて!!!実装が!!!!私には無理なんすよ!!!!!!
 ここに挙げたコードに、もしかしたらD列に画像を乗っけようとした痕跡があるかもだけど、有識者の方々ここは任せた!!!
 そもそも受験生なんで、こんなことやってる場合じゃないね。(共通テスト55日前)

あと、Threadsも同様の実装を考えてるで。これは将来性あり。
 APIの取得がめんどいだけだから、コードはそこまで難しくないと思う。ね?()
 Threads、まっててな!!!!!!!!!!!!いまそこにいく!!!!!!!!!!

注意点

・短期間に実行を繰り返すと、429っていうエラーが表示されるよ。これはXのAPIが短期間のアクセスをブロックしてるだけなので、あまり気にしないで。
 ・Xのほうでポストを削除しても、Blueskyのほうは自動で削除されないよ。(多分)やましいことはSNSにおくるようなもんじゃないから、これで困る人は多分いないよね~(圧)
 ・なんかあれば、気軽に私に連絡してね。YouTubeとかやってるから、一人でもDMが送ってこられると喜ぶかも。そのうち喜びすぎて地球崩壊してしまうね(?)改善点とかバグとか有識者のコメントとか素人質問とかどうぞどうぞ。下のリンクから、匿名でメッセージを送れるサービス「ましゅまろ」にたどり着くから、そちらもぜひ! 
 ・画像、おまえはゆるさねえ。

以上!!!!

https://lit.link//kibu1267
私のリンクまとめ↑

https://www.youtube.com/c/kibuchi
YouTubeもやってるよ!!!↑ゲーム中心!!!

https://x.com/kibu1267
青い鳥が飛んでしまったSNSだよ↑

https://bsky.app/profile/kibu1267.bsky.social
Bluesky!現在も自動投稿継続中!↑

それでは、正真正銘の以上!!!🍕🍕🍕

Discussion