Skip to content
Go back

GAS を使って特定 Twitter アカウントのツイートを Discord チャンネルに流すツールの作り方

公開: 更新:
この記事は steins.gg から移行した記事です。

.gg ドメインの維持費が高く、サイト運用を継続できなかったため steins.gg は閉鎖しました。

おはこんばんちは! GAS を使って特定 Twitter アカウントのツイートを Discord チャンネルに流すツールを作ってみました。
コピペで誰でも使えるので、ぜひ使ってみてください。

目次

Open 目次

挙動

Twitter のアカウントごとに最新のツイートを取得して、指定した Discord チャンネル / スレッドへ自動で流します。

  • ※1 同じツイートを何回も流さないように、前回処理したツイートIDを Script PropertiesTWEET_ID)に保存し、次回は since_id で差分だけ取る
  • ※2 取得したツイートは古い順に並べて、Discord 送信は 1ツイート=1秒間隔で流す
  • ※3 source に広告っぽい文字列が入っているツイートは除外する
  • ※4 RT は「RT元のユーザー名・アイコン」で投稿しつつ、送信先のスレッドは RTした側のアカウントに紐づける
  • ※7 エラーが多いと停止し、成功すると ERROR_COUNT を少しずつ戻す

以下は私向けの独自機能です。真似する際は消してよいコードですが、「独自の判定」も追加できるサンプルとして紹介します。

  • ※5 PlayApex は「自分以外へのリプ」を除外する
  • ※6 ApexMapBot は「クラフトローテーション」以外を除外する

実装

appscript.json

{
  "timeZone": "Asia/Tokyo",
  "dependencies": {},
  "exceptionLogging": "STACKDRIVER",
  "runtimeVersion": "DEPRECATED_ES5"
}

main.gs

// Script Properties のキー(※1, ※7)
TWEET_ID = "TWEET_ID";
ERROR_COUNT = "ERROR_COUNT";

// Discord Webhook URL(チャンネル投稿)
// あなたが投稿したいチャンネルの Webhook URL を指定してください。
WEBHOOK_URL = "https://discord.com/api/webhooks/10302~~~~2318571682/djsOkj561fZ-R7E~~~~~~~~~~~~nGWljsDWEWpKZWwuh-38m3L55SKhM3JQ6MfT";

// 広告判定用の source 文字列(※3)
PROMOTION_SOURCE = "Twitter for Advertisers";
BELUGA_CAMPAIGN_SEA_SOURCE = "BelugaCampaign";
PROMOTION_SOURCE2 = "advertiser";

// configMap は Twitter ID を key に、Discord の送信先を設定したマップです。
// - threadId: Discord の thread_id
// - postTopChanel: true ならトップチャンネルにも流す(※4, ※5)
var configMap = {
  gundamevo_jp: { threadId: "1062662~~~~6183060", postTopChanel: true },
  gggp_official: { threadId: "1062662~~~~56183060", postTopChanel: false },
  valorantjp: { threadId: "1062661~~~~10087937", postTopChanel: true },
  jpplayoverwatch: { threadId: "1062662~~~~44773170", postTopChanel: true },
  playapex: { threadId: null, postTopChanel: true },
  gamewith_apex: { threadId: "10626700~~~~2572427", postTopChanel: false },
  apexmapbot: { threadId: "1062670~~~~92572427", postTopChanel: false },
  nikke_japan: { threadId: "10531766~~~~3132328", postTopChanel: false },
  blueprotocol_jp: { threadId: "105317~~~~351677480", postTopChanel: false },
  pubg_japan: { threadId: null, postTopChanel: true },
};

var options = {
  method: "get",
  headers: {
    authorization: "Bearer AAAAAAAAAAAAAAAAAAAAAC7OTAEAAAAA.....",
  },
  muteHttpExceptions: true,
};

function main() {
  // ※7 エラーが溜まりすぎたら止める
  if (Number(PropertiesService.getScriptProperties().getProperty(ERROR_COUNT)) > 80) {
    Logger.log("skip");
    return;
  }

  // ※1 前回処理したツイートID(since_id に使う)
  var tweetId = PropertiesService.getScriptProperties().getProperty(TWEET_ID);

  // 対象アカウントを from:xxx OR from:yyy ... でまとめて検索する
  var query = "";
  Object.keys(configMap).forEach(function (configKey) {
    query += "from:" + configKey + " OR ";
  });
  query = query.slice(0, -3); // 末尾の " OR " を削る
  console.log(query);

  var url = "https://api.twitter.com/2/tweets/search/recent?max_results=100&since_id=" + tweetId // ※1
    + "&query=(" + query + ")"
    + "&user.fields=username&tweet.fields=source";
  //  -is:retweet を付けると RT を除外できる(※4 の挙動が不要な人向け)
  //+ "%20-is:retweet&user.fields=username&tweet.fields=source"; 
  //  -is:reply を付けると リプ を除外できる(※5 の独自判定が不要な人向け)
  //+ "%20-is:retweet%20-is:reply&user.fields=username&tweet.fields=source"; 

  // 取得したツイートをまとめて受け取る
  var response = JSON.parse(UrlFetchApp.fetch(url, options));
  Logger.log(response);

  if (response.data == null) {
    Logger.log("No data");
    return;
  }

  try {
    // ※2 取得したツイートを古い順にする(古い→新しい順で Discord に流す)
    var reversed = response.data.reverse();

    reversed.forEach(function (data) {
      sendToDiscord(data);
      Utilities.sleep(1000); // ※2 1ツイートを1秒間隔で送る(レートリミット対策)
    });
  } catch (e) {
    // ※7 失敗したらカウントを増やす(Discord / Twitter API の障害中に実行し続けないように)
    var error = PropertiesService.getScriptProperties().getProperty(ERROR_COUNT);
    PropertiesService.getScriptProperties().setProperty(ERROR_COUNT, Number(error) + 1);
    PropertiesService.getScriptProperties().setProperty("ERROR", e.toString());
    Logger.log(e);
  }
}

// Discord に送るための関数
function sendToDiscord(data) {
  // ツイートの詳細データを取得
  var url2 = "https://api.twitter.com/2/tweets/" + data.id + "?tweet.fields=entities,context_annotations";
  var response2 = JSON.parse(UrlFetchApp.fetch(url2, options));
  Logger.log(response2);

  // ※3 広告っぽい source のツイートは除外する
  if (
    response2.source.toString().indexOf(PROMOTION_SOURCE) >= 0 ||
    response2.source.toString().indexOf(BELUGA_CAMPAIGN_SEA_SOURCE) >= 0 ||
    response2.source.toString().indexOf(PROMOTION_SOURCE2) >= 0
  ) {
    Logger.log("promotion");
    Logger.log(response2.source.toString());

    // ※1 このツイートIDまで処理済みとして保存(次回は since_id で差分取得)
    PropertiesService.getScriptProperties().setProperty(TWEET_ID, data.id);
    return;
  }

  var payload = {};

  if (response2.retweeted_status !== undefined) {
    // ※4 RT の場合:
    // - 表示名/アイコンは RT元のユーザー(retweeted_status.user)
    // - 送信先のスレッドは RTした側(response2.user.screen_name)に紐づける
    if (response2.retweeted_status.user.name.toUpperCase().indexOf("DISCORD") >= 0) {
      // Discord の規約で、投稿名に Discord という文字列を含めることができないので、
      // 名前に Discord を含む場合だけ o をそれっぽい文字に置換して回避する
      payload.username = response2.retweeted_status.user.name.replace("o", "ᴑ");
    } else {
      payload.username = response2.retweeted_status.user.name;
    }
    payload.avatar_url = response2.retweeted_status.user.profile_image_url.replace("_normal", "");
    payload.content =
      "Retweeted by @" + response2.user.screen_name + "\n" +
      "https://twitter.com/" + response2.retweeted_status.user.screen_name +
      "/status/" + response2.retweeted_status.id_str;

    postToWebhook(payload, response2.user.screen_name);
  } else if (
      // ※5(独自機能)PlayApex は「自分以外へのリプ」を除外する
      // 公式アカウントでもめっちゃリプを返すアカウントがあるので、そういう場合はこのような判定が便利
      response2.in_reply_to_screen_name != null &&
      response2.in_reply_to_screen_name != response2.user.screen_name &&
      response2.user.screen_name == "PlayApex") {

    Logger.log(response2.in_reply_to_screen_name);
    Logger.log(response2.user.screen_name);
    Logger.log("Skip replies to other users by PlayApex.");
  } else if (
      // ※6(独自機能)ApexMapBot は「クラフトローテーション」以外を除外する
      // 特定アカウントの「特定ワードを含むツイートだけ」拾いたいときにこういう判定を入れてください
      response2.user.screen_name == "ApexMapBot" &&
      response2.text.indexOf("クラフトローテーション") == -1) {

    Logger.log("Skip not craft rotation by ApexMapBot.");
  } else {

    // Tweet Payload
    Logger.log(response2.user.screen_name);
    payload.username = response2.user.name;
    payload.avatar_url = response2.user.profile_image_url.replace("_normal", "");
    payload.content =
      "https://twitter.com/" + response2.user.screen_name + "/status/" + data.id;

    postToWebhook(payload, response2.user.screen_name);
  }

  // ※1 このツイートIDまで処理済みとして保存
  PropertiesService.getScriptProperties().setProperty(TWEET_ID, data.id);
}

function postToWebhook(payload, screenName) {
  // configMap に存在しない screenName が来ると落ちるので、基本は対象アカウントのキーと一致させること
  var config = configMap[screenName.toLowerCase()];

  // Top channel に送信
  if (config.postTopChanel) {
    UrlFetchApp.fetch(WEBHOOK_URL, {
      method: "post",
      contentType: "application/json",
      payload: JSON.stringify(payload),
    });
  }

  // thread に送信(webhook に thread_id を付けて投げる)
  if (config.threadId != null) {
    UrlFetchApp.fetch(WEBHOOK_URL + "?thread_id=" + config.threadId, {
      method: "post",
      contentType: "application/json",
      payload: JSON.stringify(payload),
    });
  }

  // ※7 成功したらカウントを減らす(回復用)
  var errorCount = PropertiesService.getScriptProperties().getProperty(ERROR_COUNT);
  if (errorCount > 0) {
    PropertiesService.getScriptProperties().setProperty(ERROR_COUNT, Number(errorCount) - 1);
  }
}

運用方法

main() 関数を時間主導トリガーで1分おきに設定して自動実行してください。

運用サンプル

こんな感じで表示されます。
様々なアカウントの最新情報を、指定した Discord チャンネル・スレッドでまとめてチェックできるのでとても便利です。

twitter-to-discord

Share this post on:

Previous Post
【PUBG】限定公開している試合分析システムを超絶アップデートしました
Next Post
【PUBG】試合分析システム Steins.GG を限定公開で復活させました