【ツイート日報】 twitter でのエゴサーチ結果を集約して slack に投稿する

## 更新
2021-05-09：GAS が V8 ランタイムに対応したので全面的に書き直し。

# つくったもの
twitter で特定のキーワードがつぶやかれたときに slack に投稿するという仕組みは各所で多くが紹介されています。代表的なものに [IFTTT](https://ifttt.com) を利用する方法がありますが、頻繁につぶやかれるようなキーワードの場合、大量の通知によって本来見るべき通知が埋もれてしまうという問題が無視できません。
集中力の妨げにもなりますし、実際には1日につき一度でも確認すれば十分なことが大半ですので、 **前日につぶやかれたツイートを集約して毎日定時で報告するBOT** を作成してみました。

![20191062164406_1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/501098/f862a969-9448-8f61-0b7d-e85fd6b45188.png)


# 全体的な仕組み

```
IFTTT でツイートを随時取得
　↓
google スプレッドシートにログ
　↓
ログ中の前日に追加された部分を GAS で集約
　↓
Incoming webhook で slack に投稿
```

# 準備1： IFTTT アプレットの作成

特定のキーワードが検索されたらその内容を google スプレッドシートに1行ずつ加筆していくアプレットを用意します。当初は検索クエリを組み合わせて BOT などを弾くようにしていましたが、IFTTT 側を頻繁に更新するのが億劫になったので後述のように GAS の側で対処するようにしました。

![20191062120600.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/501098/49694363-5dbc-f86c-7054-2f0af3b97ee4.png)

# 準備2： Incoming webhook の URL 取得

slack にアプリを登録して、 特定のチャンネルに投稿するための Webhook URL を取得しておきます。

![20191062121910.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/501098/e84ce39f-b5e6-8e19-2f82-27017e774c10.png)


# 準備3： GoogleAppsScript を書く

## 3-1. slack への投稿用関数

```javascript

// slackに投稿する関数
function send2slack(hookURL, messageBlock) {
  const payload = {
    "text":"ツイート日報",
    "blocks": messageBlock
  }
  const options = {
    "method" : "POST",
    "payload": JSON.stringify(payload)
  }
  UrlFetchApp.fetch(hookURL, options);
}

// slack の block を作る関数
function makeBlockContent (author, iconUrl, tweet, source) {
  return  {
    "type": "context",
    "elements": [
      {
        "type": "image",
        "image_url": iconUrl,
        "alt_text": "images"
      },
      {
        "type": "mrkdwn",
        "text": "<" + source + "|" + author + ">"
      },
      {
        "type": "mrkdwn",
        "text": tweet + "\r\n" + "-".repeat(120)
      }
    ]
  }
}

// slack への投稿の見出し作成する関数
function makeHeader (index, trial, totalItem) {
  return {
    "type": "section",
    "text": {
      "type": "mrkdwn",
      "text": "昨日は *" + totalItem + "* 件のツイートで言及されました！［" + index + "/" + trial + "］"
    }
  };
}

```

## 3-2. BOT による投稿などを除外

2枚目のシートを追加し、日報に上がってきてほしくないユーザー名を列挙しておきます。
![20191062122652_1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/501098/f3fd6162-286b-41c3-0ddd-572458c13f2a.png)

GAS には下記の関数を追加し、この除外ユーザーを配列形式で取得できるようにしておきます。

```javascript
// シートから除外ユーザを取得する関数
function getExcludeUser (sheetId) {
  const sht = SpreadsheetApp.openById(sheetId).getSheets();
  const maxRow = sht[1].getLastRow();
  const data = sht[1].getRange(2, 1, maxRow-1, 1).getValues();
  return data.map(d => d[0])
}
```

## 3-3. その他下準備

無効な URL を slack に投稿しようとするとエラーになるようなので、事前にチェックするための関数を書いておきます。


```javascript

// url が存在しているか確かめる関数
function isValidUrl (url) {
  try {
    UrlFetchApp.fetch(url);
  }
  catch (e) {
    return false;
  }
  return true;
}

```

IFTTT で記録されるタイムスタンプはそのままでは扱いづらい形式なので、とりあえず必要な日付部分だけを取り出す関数を用意します。

```javascript

// IFTTT のタイムスタンプから日付部分を取り出す関数
function getDateFromTimestamp(ts) {
  const m  = ts.match(/[A-Z][a-z]+ (\d{1,2}), \d{4}/);
  return Number(m[1]);
}

```

## 3-4. メイン処理
日付操作用のライブラリとして [moment](https://momentjs.com/) を使用しています。

```javascript

// 前日に取得されたツイートを取得する関数
function getTweetsOfYesterday(sheetId) {
  
  const sht = SpreadsheetApp.openById(sheetId).getSheets();

  const mmt = Moment.moment();
  const today = mmt.date();
  const lastDate = mmt.add(-1,"days").date();
  
  const maxRow = sht[0].getLastRow();
  const data = sht[0].getRange(1,1,maxRow,5).getValues();
  
  const tweets = [];
  for(let r = maxRow-1; r >= 1; r--) {
    const dateTs = getDateFromTimestamp(data[r][0]);
    
    // その日になってからつぶやかれたツイートは飛ばす
    if (dateTs == Number(today)) {
      continue;
    }
    // 2日以上前に入ったらループ打ち切り
    if (dateTs != Number(lastDate)) {
      break;
    }

    tweets.push(data[r].slice(1));
  }
  
  return tweets;

}

// slack 投稿用の block を生成する関数
function newMessageBlock(sheetId) {
  const excludeUsers = getExcludeUser(sheetId);
  const tweets = getTweetsOfYesterday(sheetId);
  return tweets.map(twt => {
    const [author,iconUrl,tweet,source] = twt;
    if (excludeUsers.indexOf(author) < 0 && isValidUrl(iconUrl)) {
      return makeBlockContent(author, iconUrl, tweet, source);
    }
  }).filter(Boolean);
}

// slack 投稿関数
function postTweets(sheetId, hookURL) {
  
  const messageBlock = newMessageBlock(sheetId);
  const nTweet = messageBlock.length;
  if (nTweet < 1) {
    return;
  }
  
  // 一度の投稿に付与できる block の上限は50のようなので安全策で45件ずつ投稿
  const unit = 45;
  const trial = Math.ceil(nTweet/unit);
  let counter = 1;
  let unitBlock = [];
  for (let i = 0; i < messageBlock.length; i++) {
    unitBlock.push(messageBlock[i]);
    if ((i + 1) % unit == 0) {
      send2slack(hookURL, [makeHeader(counter, trial, nTweet), ...unitBlock]);
      unitBlock = []; // 配列をリセット
      counter++
    }
  }
  
  // 残りを投稿
  if (unitBlock.length > 0) {
    send2slack(hookURL, [makeHeader(counter, trial, nTweet), ...unitBlock]);
  }
}

// ログ件数をチェックする関数
function checkLogCount (sheetId, msg) {
  const sht = SpreadsheetApp.openById(sheetId).getSheets();
  const maxRow = sht[0].getLastRow();
  if (maxRow > 1500) {
    MailApp.sendEmail("xxxxxxxxxxxxxx@gmail.com", "log sheet warning", msg);
  }
}

function main () {
  
  const sId = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
  const hookUrl = "https://hooks.slack.com/services/xxxxxxx/xxxxxxxx/xxxxxxxxxxxx";
  postTweets(sId, hookUrl);
  checkLogCount(sId, "ログが1500件以上溜まっています。そろそろシートを整理しましょう。");

}

```


## 3-5. トリガーの設定

上記 `main()` を定時実行するように設定し、毎朝特定のチャンネルに投稿するようにします。

IFTTT の仕様として、google スプレッドシートの行が数千を超えると自動的に新規シートが作成されてそちらに追記されるようになります。
将来的には一定期間経過したログを別シートに書き出すなどの自動化も目指していますが、とりあえず今はログを監視してメールで通知されたときに手動で対処しています。


# slack の blocks に関する注意点

+ 画像の URL が無効だとエラー
+ 一度の投稿に付与できる block の数は最大で50まで

slack の blocks の仕様の中でも、特に上記2点については `invalid_blocks` のすげないエラーが返るばかりで原因特定に苦労しました。

# コード全体
結構な長さになってしまいました。
<details><summary>全文表示</summary><div>

```tweetReport.js
/*
  1日前のツイートをエゴサーチして集約・報告
  使用ライブラリ： Moment
*/


////////////////////////////////////////////////////////
// slack 関連
////////////////////////////////////////////////////////

// slackに投稿する関数
function send2slack(hookURL, messageBlock) {
    const payload = {
      "text":"ツイート日報",
      "blocks": messageBlock
    }
    const options = {
      "method" : "POST",
      "payload": JSON.stringify(payload)
    }
    UrlFetchApp.fetch(hookURL, options);
  }

// slack の block を作る関数
function makeBlockContent (author, iconUrl, tweet, source) {
  return  {
    "type": "context",
    "elements": [
      {
        "type": "image",
        "image_url": iconUrl,
        "alt_text": "images"
      },
      {
        "type": "mrkdwn",
        "text": "<" + source + "|" + author + ">"
      },
      {
        "type": "mrkdwn",
        "text": tweet + "\r\n" + "-".repeat(120)
      }
    ]
  }
}

// slack への投稿の見出し作成する関数
function makeHeader (index, trial, totalItem) {
  return {
    "type": "section",
    "text": {
      "type": "mrkdwn",
      "text": "昨日は *" + totalItem + "* 件のツイートで言及されました！［" + index + "/" + trial + "］"
    }
  };
}

////////////////////////////////////////////////////////
// 事前処理
////////////////////////////////////////////////////////

// シートから除外ユーザを取得する関数
function getExcludeUser (sheetId) {
  const sht = SpreadsheetApp.openById(sheetId).getSheets();
  const maxRow = sht[1].getLastRow();
  const data = sht[1].getRange(2, 1, maxRow-1, 1).getValues();
  return data.map(d => d[0])
}

// url が存在しているか確かめる関数
function isValidUrl (url) {
  try {
    UrlFetchApp.fetch(url);
  }
  catch (e) {
    return false;
  }
  return true;
}

// IFTTT のタイムスタンプから日付部分を取り出す関数
function getDateFromTimestamp(ts) {
  const m  = ts.match(/[A-Z][a-z]+ (\d{1,2}), \d{4}/);
  return Number(m[1]);
}

////////////////////////////////////////////////////////
// メイン処理
////////////////////////////////////////////////////////

// 前日に取得されたツイートを取得する関数
function getTweetsOfYesterday(sheetId) {
  
  const sht = SpreadsheetApp.openById(sheetId).getSheets();

  const mmt = Moment.moment();
  const today = mmt.date();
  const lastDate = mmt.add(-1,"days").date();
  
  const maxRow = sht[0].getLastRow();
  const data = sht[0].getRange(1,1,maxRow,5).getValues();
  
  const tweets = [];
  for(let r = maxRow-1; r >= 1; r--) {
    const dateTs = getDateFromTimestamp(data[r][0]);
    
    // その日になってからつぶやかれたツイートは飛ばす
    if (dateTs == Number(today)) {
      continue;
    }
    // 2日以上前に入ったらループ打ち切り
    if (dateTs != Number(lastDate)) {
      break;
    }

    tweets.push(data[r].slice(1));
  }
  
  return tweets;

}

// slack 投稿用の block を生成する関数
function newMessageBlock(sheetId) {
  const excludeUsers = getExcludeUser(sheetId);
  const tweets = getTweetsOfYesterday(sheetId);
  return tweets.map(twt => {
    const [author,iconUrl,tweet,source] = twt;
    if (excludeUsers.indexOf(author) < 0 && isValidUrl(iconUrl)) {
      return makeBlockContent(author, iconUrl, tweet, source);
    }
  }).filter(Boolean);
}

// slack 投稿関数
function postTweets(sheetId, hookURL) {
  
  const messageBlock = newMessageBlock(sheetId);
  const nTweet = messageBlock.length;
  if (nTweet < 1) {
    return;
  }
  
  // 一度の投稿に付与できる block の上限は50のようなので安全策で45件ずつ投稿
  const unit = 45;
  const trial = Math.ceil(nTweet/unit);
  let counter = 1;
  let unitBlock = [];
  for (let i = 0; i < messageBlock.length; i++) {
    unitBlock.push(messageBlock[i]);
    if ((i + 1) % unit == 0) {
      send2slack(hookURL, [makeHeader(counter, trial, nTweet), ...unitBlock]);
      unitBlock = []; // 配列をリセット
      counter++
    }
  }
  
  // 残りを投稿
  if (unitBlock.length > 0) {
    send2slack(hookURL, [makeHeader(counter, trial, nTweet), ...unitBlock]);
  }
}

// ログ件数をチェックする関数
function checkLogCount (sheetId, msg) {
  const sht = SpreadsheetApp.openById(sheetId).getSheets();
  const maxRow = sht[0].getLastRow();
  if (maxRow > 1500) {
    MailApp.sendEmail("xxxxxxxxxxxxxx@gmail.com", "log sheet warning", msg);
  }
}

function main () {
  
  const sId = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
  const hookUrl = "https://hooks.slack.com/services/xxxxxxx/xxxxxxxx/xxxxxxxxxxxx";
  postTweets(sId, hookUrl);
  checkLogCount(sId, "ログが1500件以上溜まっています。そろそろシートを整理しましょう。");

}

```

</div></details>