X(旧Twitter)のブックマーク運用を効率化した話|Gmail + GAS + ChatGPTで整理・検索性を爆上げ!

に公開

はじめに

こんにちは。皆さんX(Twitter)のブックマークの整理、どうしてますか?

今回は、X(旧Twitter)のブックマーク機能の不便さ/使いづらさを解消し、自分だけの「ナレッジ・アーカイブ」をGmailとスプレッドシート、そしてChatGPTで自動整理する仕組みを構築した話を共有します。

背景と課題

Xの「ブックマーク」機能は便利ではあるのですが…

  • フォルダ分類やタグ付けができない
  • 情報が時系列で埋もれる
  • 振り返りが難しい
  • 会社PCなど別環境からアクセスできない(業務で参照しづらい)

といった問題を感じていました。特に、ITや生成AI関連の情報はXで追いかけるのが一番早いのですが、せっかくブックマークした情報をタイムリーに仕事で参照できないのが地味にストレスでした。

解決方法の概要

私が最終的に構築した仕組みは、以下のような流れです:

  1. Xで気になった投稿を「Gmailに転送」(スマホの共有メニューから2ポチくらい)
  2. GAS(Google Apps Script)でメールを監視
  3. 対象のメールからツイートURL・本文を抽出してスプレッドシートに転記
  4. ChatGPT APIを使って自動で「カテゴリ」「キーワード」などを抽出
  5. スプレッドシートをナレッジベースとして活用(検索・既読管理・殿堂入りフラグなども)

図解(全体構成)

使用した主な技術・サービス

  • Gmail(メール転送)
  • Google Apps Script(自動処理・定期実行)
  • Google スプレッドシート(ナレッジデータベース)
  • OpenAI GPT-4o-mini API(自然言語処理)

アウトプットイメージ

スクリプトの構成

主なスクリプト関数は以下の4つです:

  • runDailyTweetImportAndEnrichment():メールからツイートURL・本文を抽出し、行追加
  • enrichSheetWithChatGPT:ChatGPT APIでカテゴリ自動分類&キーワード抽出
  • callChatGPT(prompt):共通のAPI呼び出し関数

定期実行はGASのトリガー(9時など)を設定すればOKです。

なぜこの構成が良かったか?

  • XのAPI制限を回避できる(いいね・ブックマークAPIは厳しい。というか費用が高い…。)
  • モバイルの共有メニューから即時Gmail転送できて便利(通勤中や布団の中でポチポチ)
  • スプレッドシートでの可視性・検索性が高い
  • ChatGPTと組み合わせることで「自動ラベル付け・キーワード抽出」が可能
  • 今後も柔軟に拡張できる(Notion連携・Zapier・Slack通知…etc)

かかったコスト

ChatGPTのAPIクレジット代金:$5(約700円)

※この仕組みを運用し始めてまだ日が経っていないのでなんともですが、一週間ほど運用して$0.01ほどしかクレジットを消化していないので、このペースであれば当分は追加課金なしでいけそうです。

ChatGPTでのカテゴリ分類やキーワード抽出の自動化が不要であれば0円で実現できます。

やってみて良かったところ/躓きポイント

良かったところ

  • ブックマークを無限にスクロールして「あの記事どれだっけ?」と探し回るストレスが一気に解消されました。カテゴリやキーワード検索で目当ての記事がサクサク見つかります。
  • 会社PCから気になるニュースなどを参照しづらいストレスや手間も解消しました。チームや同僚に最新のニュースもさっと共有できます(スプシをオープンなWebページとして公開することで、会社環境からでも参照しやすくなりました)
  • ナレッジベースとして扱いやすい媒体(スプレッドシート)になったことで、今後の拡張やデータ蓄積後の活用に繋げやすくなりました(自身の興味関心の傾向分析→マッチする情報アカウントのレコメンドなど)

躓きポイント

  • メールをテキスト解析して投稿の本文やリンクを抽出する処理は、文章の形式や書き方などによって稀に上手く抽出できない場合があります。
  • ここはどこまで凝るかの世界ですが、体感でエラー率1割未満なので今回は許容しました(エラーが出たレコードはスプシを後で手作業でメンテして直してます)

今後の展望

  • ChatGPTによる要約機能の追加
  • Notionとの双方向同期
  • Slackへのハイライト通知
  • RSS化して社内共有 など

さいごに

Xの公式APIを使ったやり方とか、スクレイピングするやり方とか、他にも色々と手段はあるとは思うのですが、今回の方法は比較的ハードルが低い(技術的にもコスト的にも)やり方なので、結構再現性があるのではと思っています。

今回はXの投稿を対象にしていますが、他のSNSのお気に入りやブックマークの整理などにも応用が効くのではと思います。ChatGPTのAPIも驚くほど安く、そして気軽に使えて驚きです。すごい時代になりました。

私と同じようにXのブックマーク整理に困っている方は地味に多いのではと思います。今回の内容が少しでも参考になれば幸いです。質問・フィードバックなども大歓迎ですので、お気軽にコメントください。

(参考)実際の設定内容やスクリプト詳細など

スクリプト全文

/**
 * 毎朝9時に実行する定期処理スクリプト
 * - GmailからXのツイート情報を取得
 * - スプレッドシートに転記
 * - ChatGPTでカテゴリとキーワードを補完
 */

const SHEET_NAME = 'Sheet1'; // スプレッドシートのシート名に合わせて変更
const OPENAI_API_KEY = PropertiesService.getScriptProperties().getProperty("openai_api_key");
const GMAIL_LABEL = 'TwitterBookmarks'; // Gmailでフィルタしたラベル名に合わせて変更

function runDailyTweetImportAndEnrichment() {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(SHEET_NAME);
  const label = GmailApp.getUserLabelByName(GMAIL_LABEL);
  const threads = label.getThreads();
  const now = new Date();

  threads.forEach(thread => {
    const messages = thread.getMessages();
    messages.forEach(message => {
      const body = message.getPlainBody();
      const bodyLines = body.split("\n").map(l => l.trim()).filter(Boolean);

      // ツイートURL抽出(x.com or twitter.com 両対応)
      const urlMatch = body.match(/https?:\/\/(?:x|twitter)\.com\/[^\s))]+/);
      if (!urlMatch) {
        Logger.log("ツイートURLが見つかりませんでした。");
        return;
      }
      const tweetUrl = urlMatch[0].replace("x.com", "twitter.com");

      // ツイート本文抽出:定型文の後の行を見つける
      let tweetText = "";
      let first_line_is_url = true
      for (let i = 0; i < bodyLines.length; i++) {
        if (bodyLines[i].includes("にポストしました:")) {
          // その次の行から本文を収集(空行やURLで止める)
          for (let j = i + 1; j < bodyLines.length; j++) {
            const line = bodyLines[j];
            if (line.startsWith("http") || line === ""){
              if( !first_line_is_url ) break;
            }
            tweetText += line + " ";
            first_line_is_url = false;
          }
          break;
        }
      }
      tweetText = tweetText.trim();
    
      // 本文中の他リンク(ツイートURL以外)
      const allUrls = [...body.matchAll(/https?:\/\/[^\s))]+/g)].map(m => m[0]);
      const otherLinks = allUrls.filter(link =>
        !link.includes("twitter.com") && !link.includes("x.com")
      ).join(", ");

      // スプレッドシートに追加
      sheet.appendRow([
        new Date(),
        message.getSubject(),
        tweetText,
        otherLinks,
        tweetUrl,
        "未読", // 既読
        "", // 殿堂入り
        "", // カテゴリ
        ""  // メモ
      ]);

    });

    thread.removeLabel(label); // 処理済みとしてラベル削除
  });

  enrichSheetWithChatGPT(sheet);
}

function enrichSheetWithChatGPT(sheet) {
  const data = sheet.getDataRange().getValues();

  for (let i = 1; i < data.length; i++) {
    const row = data[i];
    const tweetText = row[2];
    const category = row[7];
    const keywords = row[8];

    // カテゴリ補完
    if (!category && tweetText) {
      const catPrompt = `
以下のツイート内容を、次のカテゴリの中から最も適切なものに分類してください:

カテゴリ候補:
- 生成AI
- プレゼン・デザイン
- プログラミング・開発
- 働き方・キャリア
- 経営・ビジネス
- ブロックチェーン
- 情シス・コーポレートIT
- 英語学習
- その他・不明

ツイート内容:
「${tweetText}」

出力はカテゴリ名のみでお願いします。
`;
      const catResult = callChatGPT(catPrompt);
      if (catResult) sheet.getRange(i + 1, 8).setValue(catResult);
      Utilities.sleep(1500);
    }

    // キーワード補完
    if (!keywords && tweetText) {
      Logger.log("キーワード補完");
      const keyPrompt = `以下の文章から、意味のあるキーワードだけを抽出してください。\n特に「生成AI」「LLM」「ChatGPT」「Claude」「Copilot」「LangChain」などの専門語・固有名詞があれば優先的に取り出してください。\n一般的な単語(例:「すごい」「使ってみた」などの感想・感情表現)は除外してください。特に抽出すべきキーワードが見つからないときは「特になし」と出力してください。\n\n${tweetText}`;
      const keyResult = callChatGPT(keyPrompt);
      Logger.log(keyResult);
      if (keyResult) sheet.getRange(i + 1, 9).setValue(keyResult);
      Utilities.sleep(1500);
    }
  }
}

function callChatGPT(prompt) {
  const payload = {
    model: 'gpt-4o-mini', // または 'gpt-3.5-turbo'
    messages: [{ role: 'user', content: prompt }],
    temperature: 0.3,
  };

  const options = {
    method: 'post',
    contentType: 'application/json',
    headers: {
      Authorization: `Bearer ${OPENAI_API_KEY}`,
    },
    payload: JSON.stringify(payload),
  };

  try {
    const response = UrlFetchApp.fetch('https://api.openai.com/v1/chat/completions', options);
    const json = JSON.parse(response.getContentText());
    return json.choices?.[0]?.message?.content?.trim();
  } catch (e) {
    Logger.log('ChatGPT APIエラー: ' + e);
    return '';
  }
}

GASの設定ポイント

  • ChatGPTのAPIキーをスクリプト本文に直書きしないよう、GASの「スクリプト プロパティ」というカスタムプロパティ定義に埋め込んでそちらを参照するようにしています。
  • GASの「トリガー」設定で、対象のスクリプトは毎朝9時に自動実行されるようにしています。

Gmailの設定など

  • Xの共有ボタンでGmailに転送した場合した場合、タイトルや本文に決まったフォーマットが入るので、それを条件に自動でラベル付けするように設定しました。
タイトル:XXさん(@YYY)からのポスト
本文:XXさん(@YYY)が8:00 午後 on 火, 6月 10, 2025にポストしました:

参考リンク

Discussion