🐦

GAS + RSSでツイートを継続的にエゴサする

2022/08/07に公開

まえがき&きっかけ

Twitter で 担当アイドル を検索してツイートを眺める ということをよくするのですが、

  • 「Twitter を開いて、検索窓タップして、保存した検索条件選んで…ってするの面倒すぎる」
  • 「RSS リーダーは頻繁にチェックするので、ここで見られれば最高なのでは…?」

という気持ちになった結果、

『Twitter の検索結果を RSS フィードにして、RSS リーダーで読めるようにしたい!』

と思ったのがきっかけです。

構成図的なもの

構成図

今回使用する Twitter API v2 の /tweets/search/recent エンドポイントは Tweet cap の対象になっており、1 ヵ月に取得できるツイート数が制限されています。

ですので、定期的に検索処理を走らせて生成した XML ファイルをキャッシュしておき、アクセス時にはキャッシュしたものを返すような仕組みにしました。

検索 Word の保存にはお馴染みスプレッドシートを、XML ファイルのキャッシュには GAS の Cache Service を利用しています。

準備

1. TwitterAPI の Bearer Token を取得

API 経由でツイートを検索するのに必要です。
取得方法については、公式ドキュメント をご参照ください。

また、今回は Twitter API v2 を利用します。

2. ディレクトリを作成

mkdir gas-twitter-rss
cd gas-twitter-rss

3. clasp をインストール

ブラウザ上のエディタを利用しても良かったのですが、今回は clasp という GAS のプロジェクトをローカルで管理できる CLI ツールを利用します。

npm もしくは yarn からインストールできます。

npm install -g @google/clasp
# or
yarn global add @google/clasp

インストールができたら、Google アカウントでログインしておきます。

clasp login

4. Google Apps Script API を有効化

clasp を使用するには Google Apps Script API を有効化する必要があります。

GAS の管理画面の左下にある「設定」を選択します。

設定ボタン

Google Apps Script API の項目を「オン」にします。

設定項目

これで clasp が使えるようになりました! 🎉

実装

1. プロジェクトを作成

さっそく clasp を使ってプロジェクトを作成していきます。

clasp create gas-ego-search
? Create which script?
  standalone
  docs
❯ sheets # 今回はスプレッドシートを使いたいのでこれを選択
  slides
  forms
  webapp
  api

種類を選択して Enter で決定。

? Create which script? sheets
Created new Google Sheet: https://drive.google.com/open?id=[ドキュメントID]
Created new Google Sheets Add-on script: https://script.google.com/d/[スクリプトID]/edit
Cloned 1 file.
└─ /path/to/gas-ego-search/appsscript.json

すると、スプレッドシートとそれに紐づいたスクリプトが作成されます。
便利… 😺

また、ここまでの操作でディレクトリ内は以下のようになります。

.
├── .clasp.json
└── appsscript.json

2. appsscript.json を編集

clasp create した際に作成される、GAS の実行環境に関する設定ファイルです。

今回は以下のような設定にしました。
詳細については 公式のドキュメント をご覧ください。

{
  // タイムゾーンを東京に変更
  "timeZone": "Asia/Tokyo",
  // Webアプリとしての公開設定
  "webapp": {
    "access": "ANYONE_ANONYMOUS",
    "executeAs": "USER_DEPLOYING"
  },
  // 以降はそのまま
  "exceptionLogging": "STACKDRIVER",
  "runtimeVersion": "V8"
}

3. スプレッドシートを編集

Created new Google Sheet: の後に続く URL へアクセスしてスプレッドシートを開き、以下のような形で検索したい Word を列挙します。

Twitter の性質上、ミュート機能は必須(のはず)ですので、そのためのエリアも用意しておきます。

スプレッドシート

4. Bearer Token をプロパティに追加

Twitter の Bearer Token をハードコードするのは気が引けるので、プロパティに追加します。
いわゆる環境変数です。

clasp open でスクリプトエディタを開いて、左ペインにある「プロジェクトの設定」からページ最下部の「スクリプトプロパティ」で設定できます。

ここでは twitterToken という名前で設定しました。

スクリプトプロパティ

5. コードを書く

このままコードを追加していっても良いのですが、rootDir を変更して

.clasp.json
{
  "scriptId": "hogehoge",
  "rootDir": "./src", // ここを変更
  "parentId": ["fugafuga"]
}

以下のようなディレクトリ構成にしました。

.
├── .clasp.json
└── /src
  ├── appsscript.json
  ├── createXML.js    # 検索結果からRSS(XML)を作成
  ├── main.js         # メイン
  ├── sheets.js       # スプレッドシートの読込み・書込み
  ├── template.html   # RSS(XML)のテンプレートファイル
  ├── twitter.js      # APIを叩いてツイートを検索
  └── util.js         # ユーティリティ

大まかな処理の流れについて説明します。

doGet()

main.js
function doGet() {
  return getCacheXml()
}

doGet() は公開した URL にアクセスがあった際に呼ばれる関数です。

getCacheXml()cache.js 内で定義されています。

cache.js
const cache = CacheService.getScriptCache()

// ...

/**
 * キャッシュしたXMLを取得する
 * @return {string} XML
 */
function getCacheXml() {
  const content = cache.get('content')

  return ContentService.createTextOutput(content).setMimeType(
    ContentService.MimeType.XML
  )
}

ですので、アクセスがあると キャッシュを取得してレスポンスを返す という動きになります。

fetch()

main.js
// ...

function fetch() {
  // スプレッドシートから検索ワードを取得
  const searchWords = getSearchWords()

  // Twitterで検索
  const results = fetchSearchResults(searchWords)

  // キャッシュを更新
  updateCache(results)
}

fetch() はツイートを検索して XML ファイルを生成、キャッシュを更新する関数です。
これをトリガで定期実行させることで、継続的なエゴサを可能にします。

getSearchWords()sheets.js に、fetchSearchResults()twitter.js に定義されています。

sheets.js
sheets.js
/**
 * 検索ワードを取得
 * @returns {string[]} 検索ワード
 */
function getSearchWords() {
  return getValuesFromSS(1)
}

/**
 * 除外するユーザーIDを取得
 * @returns {string[] ユーザーID
 */
function getIgnoreUsernames() {
  return getValuesFromSS(2)
}

/**
 * 除外するクライアントを取得
 * @returns {string[]} ユーザーID
 */
function getIgnoreClients() {
  return getValuesFromSS(3)
}

/**
 * スプレッドシートから値を取得
 * @param {number} col 列番号
 * @returns {string[]} 範囲内の値
 */
function getValuesFromSS(col) {
  const ss = SpreadsheetApp.getActiveSheet()

  const lastRow =
    ss
      .getRange(1, col)
      .getNextDataCell(SpreadsheetApp.Direction.DOWN)
      .getRow() - 1

  const values = ss.getRange(2, col, lastRow).getValues()
  return values.flat()
}
Twitter.js
twitter.js
const config = PropertiesService.getScriptProperties().getProperties()
const ignoreUsernames = getIgnoreUsernames()
const ignoreClients = getIgnoreClients()

/**
 * ツイートを検索
 * @param {string[]} keywords 検索ワード
 * @returns {any[]} 検索結果の配列
 */
function fetchSearchResults(keywords) {
  const requests = createRequests(keywords)
  const responses = UrlFetchApp.fetchAll(requests)

  const results = responses.map((res, i) => {
    const json = JSON.parse(res.getContentText())
    return createOembedData(keywords[i], json)
  })

  return results.filter(Boolean).flat()
}

/**
 * 検索ワードからリクエストを作成する
 * @param {string[]} keywords 検索ワード
 * @returns {any[]} リクエスト
 */
function createRequests(keywords) {
  return keywords.map((keyword) => {
    const urlParams = createUrlParam({
      query: encodeURIComponent(`${keyword} -is:retweet -is:reply`),
      max_results: '10',
      expansions: 'author_id,attachments.media_keys',
      'tweet.fields': 'created_at,id,source,text,entities',
      'user.fields': 'name,username',
      'media.fields': 'url'
    })

    return {
      url: `https://api.twitter.com/2/tweets/search/recent?${urlParams}`,
      headers: {
        Authorization: `Bearer ${config.twitterToken}`
      }
    }
  })
}

/**
 * 埋め込み用データを作成
 * @param {string} keyword 検索ワード
 * @param {any} json JSONオブジェクト
 * @returns {any[] | null} 埋め込み用データ
 */
function createOembedData(keyword, json) {
  const data = json?.data
  const users = json?.includes?.users
  const media = json?.includes?.media

  // 検索結果が無い
  if (!data || !users) {
    return null
  }

  const results = data
    .map((tweet) => {
      const author = users.find(({ id }) => id === tweet.author_id)

      // 除外対象ならnullを返す
      if (
        ignoreClients.includes(tweet.source) ||
        ignoreUsernames.includes(author.username)
      ) {
        return null
      }

      // 添付画像を取得
      const mediaUrl = getMediaUrl(tweet, media)

      // 投稿日時をRSS用のフォーマットに直す
      const date = Utilities.formatDate(
        new Date(tweet.created_at),
        'Asia/Tokyo',
        'E, d MMM YYYY HH:mm:ss Z'
      )

      return {
        title: `${keyword}${truncate(tweet.text, 20)}`,
        name: author.name,
        username: author.username,
        text: tweet.text,
        url: `https://twitter.com/${author.username}/status/${tweet.id}`,
        mediaUrl,
        date
      }
    })
    .filter(Boolean)

  console.log(`${keyword} : ${results.length}`)

  return results
}

/**
 * 画像URLを取得
 * @param {any} tweet ツイートフィールド
 * @param {any[]} media メディアフィールド
 * @return {string | undefined} 画像URL
 */
function getMediaUrl(tweet, media) {
  const attachments = tweet?.attachments

  // 添付メディアが存在する
  if (attachments && media) {
    const mediaKey = attachments.media_keys[0]
    return media.find(({ media_key }) => media_key === mediaKey)?.url
  }

  // リンク先のOGP画像を返す
  return tweet.entities?.urls?.[0].images?.[0].url
}

詳しくは以下のリポジトリにコードがありますので、ご参照ください…!

https://github.com/arrow2nd/gas-twitter-rss

push して反映します。

clasp push
? Manifest file has been updated. Do you want to push and overwrite? Yes
└─ src/appsscript.json
└─ src/createXML.js
└─ src/main.js
└─ src/sheets.js
└─ src/template.html
└─ src/twitter.js
└─ src/util.js
Pushed 7 files.

6. Web アプリケーションとして公開

clasp open でスクリプトエディタを開いて、「デプロイ」から「新しいデプロイ」を選択します。

デプロイ

モーダルが開くので、説明文をいい感じに書いた後、「デプロイ」をクリックします。

モーダル

以上で公開できました!

モーダル

表示されている URL にアクセスすると…
デプロイ完了

ちゃんと動いてそうです!いえー ✌️✌️✌️

7. トリガを設定

GAS のトリガ機能を使って、定期的にキャッシュを更新するようにします。

今回は 1 時間おきに fetch() を実行するようにしました。

トリガー設定
トリガー設定

使ってみる

普段使っている RSS リーダーアプリケーション feeder に登録してみます。
先ほどの URL を入力して検索。

登録画面

これを追加すれば…

フィード画面

いい感じです! 👍👍👍

あとがき

いつもの情報収集のついでに、シームレスに担当アイドルのエゴサができるようになったので、ちょっぴり QOL が上がりました。
また、自身のエゴサや特定のハッシュタグを購読するのにも便利です。(これが正しい使い方な気もする)

開発の面では、当初 TypeScript で書いていたところ gs ファイルにうまく変換されず少しつまずいたのですが、後から 制限 があることを知りました。

こういうのは最初に目を通しておくべきですね。
気を付けます。

参考

Discussion