X で「いいね」→ Pocket → Gemini 要約 → Notion & LINE 通知を全自動化する

## 背景：X中心の情報収集をどう効率化するか？

私は日々のインプットをほぼ **X のタイムライン** から得ています。しかし、気になるURLを開いた直後に読む時間が取れず、そのまま埋もれてしまうことが多々ありました。また、フォロー数が増えるにつれて流れる情報が加速し、"とりあえずいいね → 後で読む" という運用がうまくいっていませんでした。

そこで「いいね → Pocket → 要約 → Notion/LINE通知」という自動パイプラインを構築し、*気になった記事を短時間で処理できる環境* を整えました。

LLM 時代に大量の情報をただ処理すること自体に大きな価値はないと思っていますが、本フローでは、要約を見て深く読むべき記事を迅速に選定できることに意味があると思い実装しました。

## Notion に連携された要約のサンプル

以下の Notion ページは本記事で構築するワークフローを実際に動かした結果をまとめたものです。

![Screenshot 2025-05-10 at 9.12.54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184983/75912e01-4f14-4094-8916-23159c1f2c7a.png)

[Xいいね記事要約データベース](https://taigokuriyama.notion.site/1ef914af9c3980808e80ebc79086bb85?v=1ef914af9c39813e9b52000cb0572ca8)

⚠️ 費用について：本フローでは以下の有料サービスを利用します。月額料金やAPI従量課金が発生しますのでご注意ください。

- IFTTT Pro：470円/月
- （Gemini API：基本的には無料枠で十分なはず）

## 全体アーキテクチャ

```mermaid
flowchart LR
    subgraph リアルタイム
        A[ユーザー] -- いいね ♥ --> B(X)
        B -- IFTTT Webhook --> C[Pocket]
    end
    subgraph バッチ 5分ごと
        C --> D[GAS Trigger]
        D --> E{Pocket 未読記事}
        E -->|URL| F[Gemini 要約生成]
        F --> G[Notion DB]
        F --> H[LINE Notify]
    end
```

## 必要な準備

| サービス                   | 準備内容                                                         |
| ---------------------- | ------------------------------------------------------------ |
| **X (旧Twitter)**       | IFTTT公式レシピを利用（次節参照）                                          |
| **Pocket**             | [Developer Site](https://getpocket.com/developer/) でAPIキーを取得 |
| **Google Apps Script** | 新規プロジェクトを作成しt実装                               |
| **Gemini API**         | [Google AI Studio](https://aistudio.google.com/)でAPIキーを取得    |
| **Notion**             | Integration作成→シークレット取得→DB作成→Integrationを招待                   |
| **LINE Messaging API**        | [LINE Messaging API](https://developers.line.biz/ja/services/messaging-api/)でトークンを発行           |
| **IFTTT (Pro)**        | IFTTT公式レシピを使用するため有料（470円/月）|

## Xの「いいね」からPocketへ（IFTTT）

以下のIFTTT公式レシピを使用します：

> [Automatically save the first link in a Tweet that you like to your Pocket queue](https://ift.tt/52Y4xVi)

### 手順

1. 上記URLを開き、「Connect」をクリック
2. XとPocketアカウントを連携
3. 有効化で完了

これにより、Xで「いいね」した投稿に含まれる**最初のURL**が自動的にPocketへ保存されます。

## Before / After：運用の変化

|              | これまで                               | 自動化後                                 |
| ------------ | ---------------------------------- | ------------------------------------ |
| **Pocket登録** | ツイートを開き → ブラウザで記事を開き → Pocketに手動保存 | Xで♥を押すだけでPocketに自動保存                 |
| **読むタイミング**  | Pocketアプリを開いてタイトルから判断              | 5分後にGemini要約がLINEとNotionに届き、読むか判断できる |
| **記録と検索性**   | Pocketアプリに溜まるだけ。検索性やラベル管理が弱い       | Notion DBでURL・要約・コメント付きで検索・整理が可能     |

> ✅ 要約付きで記事を「先に判断」できるようになり、情報の処理速度が劇的に改善しました。

## Pocket → Gemini要約 → Notion & LINE 通知

詳細な実装例や動作の流れについては、私の以前の記事をご参照ください。

> [GAS + Gemini API で Pocket に保存した記事を自動で要約して LINE 通知する](https://qiita.com/TaigoKuriyama/items/4054c2f3a5bbcb315088)

<details><summary>サンプルコード</summary>


```js
/*******************************************************
 *  1) Pocket API：記事取得 (item_id & url)
 *******************************************************/
function getPocketArticlesWithId(since) {
  const consumerKey = getEnvironmentVariable('POCKET_CONSUMER_KEY');
  const accessToken = getEnvironmentVariable('POCKET_ACCESS_TOKEN');

  const options = {
    method: 'post',
    contentType: 'application/json',
    payload: JSON.stringify({
      consumer_key: consumerKey,
      access_token: accessToken,
      state: 'unread',     // 未読記事のみ
      sort: 'newest',      // 新しい順
      detailType: 'simple',
      // since: since,        // 前回実行以降に追加された記事のみ
      count: 20
    }),
    muteHttpExceptions: true
  };

  const response = UrlFetchApp.fetch('https://getpocket.com/v3/get', options);
  const responseCode = response.getResponseCode();

  if (responseCode !== 200) {
    const errorText = response.getContentText();
    const pocketErrorCode = response.getHeaders()['X-Error-Code'];
    const pocketErrorMessage = response.getHeaders()['X-Error'];
    logError(`Pocket API request failed with status code: ${responseCode}`, {
      errorText: errorText,
      pocketErrorCode: pocketErrorCode,
      pocketErrorMessage: pocketErrorMessage
    });
    throw new Error(`Pocket API request failed with status code: ${responseCode}`);
  }

  const json = JSON.parse(response.getContentText());
  if (json.status !== 1) {
    logError(`Pocket API returned an error: ${json.error}`, {
      errorCode: json.error_code,
      errorMessage: json.error_message
    });
    throw new Error(`Pocket API returned an error: ${json.error}`);
  }

  // item_id と resolved_url をまとめて配列で返す
  return Object.values(json.list || {}).map(article => {
    return {
      itemId: article.item_id,
      url: article.resolved_url
    };
  });
}

/*******************************************************
 *  2) Pocket API：記事をアーカイブ (既読化)
 *******************************************************/
function archivePocketItem(itemId) {
  const consumerKey = getEnvironmentVariable('POCKET_CONSUMER_KEY');
  const accessToken = getEnvironmentVariable('POCKET_ACCESS_TOKEN');

  const actions = [
    {
      action: 'archive',   // mark_read としてもOKだが、archiveが実際の既読扱いになる
      item_id: itemId
    }
  ];

  const options = {
    method: 'post',
    contentType: 'application/json',
    payload: JSON.stringify({
      consumer_key: consumerKey,
      access_token: accessToken,
      actions: actions
    }),
    muteHttpExceptions: true
  };

  const response = UrlFetchApp.fetch('https://getpocket.com/v3/send', options);
  const responseCode = response.getResponseCode();

  if (responseCode !== 200) {
    logError(`Pocket API (archive) request failed with status code: ${responseCode}`, {
      responseBody: response.getContentText()
    });
    // ここではthrowせず、ログだけ残すなど
  }
}

/*******************************************************
 *  3) Pocket API：記事を削除
 *******************************************************/
function deletePocketItem(itemId) {
  const consumerKey = getEnvironmentVariable('POCKET_CONSUMER_KEY');
  const accessToken = getEnvironmentVariable('POCKET_ACCESS_TOKEN');

  const actions = [
    {
      action: 'delete',   // これでPocketから完全削除
      item_id: itemId
    }
  ];

  const options = {
    method: 'post',
    contentType: 'application/json',
    payload: JSON.stringify({
      consumer_key: consumerKey,
      access_token: accessToken,
      actions: actions
    }),
    muteHttpExceptions: true
  };

  const response = UrlFetchApp.fetch('https://getpocket.com/v3/send', options);
  const responseCode = response.getResponseCode();

  if (responseCode !== 200) {
    logError(`Pocket API (delete) request failed with status code: ${responseCode}`, {
      responseBody: response.getContentText()
    });
    // ここではthrowせず、ログだけ残すなど
  }
}

/*******************************************************
 *  4) Notion API：データベースクエリで重複URLを確認
 *******************************************************/
function hasDuplicateInNotion(url) {
  const notionDatabaseId = getEnvironmentVariable('NOTION_DATABASE_ID');

  const queryPayload = {
    // database_id はURLパスで指定しているため、bodyには含めない
    filter: {
      property: 'URL',
      url: {
        equals: url
      }
    }
  };

  // エンドポイントURLは /v1/databases/{database_id}/query
  const response = callNotionAPI(
    `https://api.notion.com/v1/databases/${notionDatabaseId}/query`,
    queryPayload,
    'post'
  );

  // response.results が 1件以上あれば重複
  return (response.results && response.results.length > 0);
}

/*******************************************************
 *  5) Notion API：記事を新規ページとして保存
 *******************************************************/
function saveToNotion(title, url, summary, comment, executedAt) {
  const notionDatabaseId = getEnvironmentVariable('NOTION_DATABASE_ID');

  // Notionに作成するページのプロパティ
  const notionPayload = {
    parent: { database_id: notionDatabaseId },
    properties: {
      Title: {
        title: [
          {
            type: 'text',
            text: {
              content: title
            }
          }
        ]
      },
      URL: {
        url: url
      },
      Summary: {
        rich_text: [
          {
            type: 'text',
            text: {
              content: summary
            }
          }
        ]
      },
      Comment: {
        rich_text: [
          {
            type: 'text',
            text: {
              content: comment
            }
          }
        ]
      },
      ExecutedAt: {
        rich_text: [
          {
            type: 'text',
            text: {
              content: executedAt
            }
          }
        ]
      }
    }
  };

  try {
    callNotionAPI('https://api.notion.com/v1/pages', notionPayload);
  } catch (error) {
    logError(`Error creating Notion page for ${url}`, error);
    // エラーが起きてもここではthrowせず、処理続行
  }
}

/*******************************************************
 *  6) Gemini API：記事本文を要約する
 *******************************************************/
function summarizeWithGemini(text) {
  const apiKey = getEnvironmentVariable('GEMINI_API_KEY');
  const model = getEnvironmentVariable('MODEL_NAME');
  const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`;

  const payload = {
    contents: [{
      parts: [{
        text: `
あなたは与えられた記事本文をよく読み、以下のJSON形式でのみ出力してください。
JSON以外の文字や文章は一切出力しないでください。必ず厳密にvalidなJSONを返してください。

- "summary" フィールド： 
  - テキストの重要な論点を日本語で要約 
  - 箇条書き形式で3〜5点程度 
  - 合計300文字以内で簡潔に書く
  - 文体やレトリック、冗長表現は排除する
  - 箇条書きを読めば、記事全体の概要が理解できるようにする

- "comment" フィールド：
  - 記事を「読む価値」や「誰に役立つか」を述べる 
  - 150文字以内で書く

形式の例：
{
  "summary": "- 主要なポイント\\n- 主要なポイント",
  "comment": "どういう人が読むと有益か、または読まなくてもよい場合を示す"
}

【入力テキスト】
${text}
`
      }],
    }],
    generationConfig: {
      temperature: 0.0,
      maxOutputTokens: 1024,
      responseMimeType: 'application/json',
      response_schema: {
        type: 'OBJECT',
        properties: {
          summary: {
            type: 'STRING',
            maxLength: 200
          },
          comment: {
            type: 'STRING',
            maxLength: 150
          }
        },
        required: ['summary', 'comment']
      }
    }
  };

  const options = {
    method: 'post',
    contentType: 'application/json',
    payload: JSON.stringify(payload),
    muteHttpExceptions: true
  };

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

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

  const json = JSON.parse(response.getContentText());

  // Gemini API のレスポンスをパース
  if (
    json.candidates &&
    json.candidates.length > 0 &&
    json.candidates[0].content &&
    json.candidates[0].content.parts
  ) {
    try {
      const responseJson = JSON.parse(json.candidates[0].content.parts[0].text);
      const summary = responseJson.summary;
      const comment = responseJson.comment;
      return { summary, comment };
    } catch (e) {
      logError('Error parsing JSON from Gemini API response:', {
        responseText: json.candidates[0].content.parts[0].text
      });
      throw new Error('Error parsing JSON from Gemini API response');
    }
  } else {
    logError('Unexpected response format from Gemini API:', {
      responseJson: JSON.stringify(json, null, 2)
    });
    throw new Error('Unexpected response format from Gemini API');
  }
}

/*******************************************************
 *  7) 記事の本文を簡易抽出
 *******************************************************/
function extractArticleContent(url) {
  try {
    // ヘッダーでUser-Agentを偽装する
    // （ブラウザを示す文字列なら何でもよいが、最新のChrome/Firefox風などが無難）
  const options = {
    method: 'get',
    headers: {
      'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:110.0) Gecko/20100101 Firefox/110.0',
      'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
      'Accept-Language': 'ja,en-US;q=0.7,en;q=0.3',
      // 'Referer': 'https://www.google.com/', // 必要な場合のみ
    },
    muteHttpExceptions: true
  };

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

    // HTTPステータスコードが4xx/5xxの場合はエラー扱いにする (必要に応じて調整)
    if (statusCode >= 400) {
      // サイトのHTML全部をログに残すと多すぎることがあるので、先頭200文字だけ
      const responseBody = response.getContentText().substring(0, 200);
      throw new Error(`Fetch failed with status ${statusCode}.\n` +
                      `Response body (truncated): ${responseBody}`);
    }

    const html = response.getContentText();

    // ---- ここから先は本文抽出の処理例 ----

    // 簡易的なタイトル抽出
    const titleStart = html.indexOf('<title>');
    const titleEnd = html.indexOf('</title>', titleStart);
    const title = (titleStart !== -1 && titleEnd !== -1)
      ? html.substring(titleStart + 7, titleEnd).trim()
      : url;

    // <body> 要素を抜き出す
    const bodyStart = html.indexOf('<body');
    const bodyEnd = html.indexOf('</body>', bodyStart);
    let bodyContent = (bodyStart !== -1 && bodyEnd !== -1)
      ? html.substring(bodyStart, bodyEnd)
      : html;

    // タグを除去（不要なら削除可）
    bodyContent = bodyContent.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
    bodyContent = bodyContent.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, '');
    bodyContent = bodyContent.replace(/<[^>]+>/g, '');
    bodyContent = bodyContent.replace(/ /g, ' '); // &nbsp;→スペース
    bodyContent = bodyContent.replace(/\s+/g, ' ').trim();

    // 処理結果を返す
    return { title, bodyContent };

  } catch (error) {
    // エラー情報を詳細にログに残す
    logError(`Error extracting content from ${url}`, {
      name: error.name || 'UnknownError',
      message: error.message || error.toString(),
      stack: error.stack || '(no stack trace)'
    });
    throw new Error(
      `Error extracting content from ${url}: ` + 
      (error.message || error.toString())
    );
  }
}

/*******************************************************
 *  8) LINE通知 (Flex Message: carousel形式)
 *******************************************************/
function sendFlexMessageBatch(articles) {
  // articles: [{title, url, summary, comment}, ...]

  const lineAccessToken = getEnvironmentVariable('LINE_CHANNEL_ACCESS_TOKEN');
  const destinationId = getEnvironmentVariable('LINE_USER_ID');

  // 個別記事ごとに bubble を作る
  const bubbles = articles.map(article => {
    return {
      type: 'bubble',
      size: 'giga',
      body: {
        type: 'box',
        layout: 'vertical',
        contents: [
          {
            type: 'text',
            text: article.title,
            weight: 'bold',
            size: 'sm',
            wrap: true,
            margin: 'none',
            color: '#0000FF', // リンク色
            decoration: 'underline',
            action: {
              type: 'uri',
              label: 'web',
              uri: article.url
            }
          },
          {
            type: 'separator',
            margin: 'lg'
          },
          {
            type: 'box',
            layout: 'vertical',
            margin: 'lg',
            spacing: 'sm',
            contents: [
              {
                type: 'text',
                text: article.summary,
                wrap: true,
                size: 'sm'
              }
            ]
          },
          {
            type: 'box',
            layout: 'vertical',
            margin: 'lg',
            spacing: 'sm',
            contents: [
              {
                type: 'text',
                text: article.comment,
                wrap: true,
                size: 'sm'
              }
            ]
          }
        ]
      }
    };
  });

  // carousel コンテナにまとめる
  const carousel = {
    type: 'carousel',
    contents: bubbles
  };

  const options = {
    method: 'post',
    headers: {
      'Authorization': `Bearer ${lineAccessToken}`,
      'Content-Type': 'application/json'
    },
    payload: JSON.stringify({
      to: destinationId,
      messages: [
        {
          type: 'flex',
          altText: '記事要約',
          contents: carousel
        }
      ]
    }),
    muteHttpExceptions: true
  };

  const response = UrlFetchApp.fetch('https://api.line.me/v2/bot/message/push', options);
  const responseCode = response.getResponseCode();

  if (responseCode !== 200) {
    logError(`LINE Messaging API request failed with status code: ${responseCode}`, {
      responseBody: response.getContentText()
    });
    throw new Error(`LINE Messaging API request failed with status code: ${responseCode}`);
  }

  const json = JSON.parse(response.getContentText());
  if (json.message) {
    logError(`LINE Messaging API returned an error: ${json.message}`, {
      errorCode: responseCode,
      errorMessage: json.message
    });
    throw new Error(`LINE Messaging API returned an error: ${json.message}`);
  }
}

/*******************************************************
 *  9) Notion API 呼び出し共通関数
 *******************************************************/
function callNotionAPI(url, payload = null, method = 'post') {
  const notionApiKey = getEnvironmentVariable('NOTION_API_KEY');
  const options = {
    method: method,
    headers: {
      'Authorization': `Bearer ${notionApiKey}`,
      'Content-Type': 'application/json',
      'Notion-Version': '2022-06-28'
    },
    muteHttpExceptions: true
  };

  if (payload) {
    options.payload = JSON.stringify(payload);
  }

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

  if (responseCode < 200 || responseCode >= 300) {
    logError(`Notion API request failed with status code: ${responseCode}`, { responseBody });
    throw new Error(`Notion API request failed with status code: ${responseCode}`);
  }

  // 2xxの場合はJSONを返す
  try {
    return JSON.parse(responseBody);
  } catch (error) {
    // JSONでなければそのまま返す
    return responseBody;
  }
}

/**
 * 10) メイン関数：Pocket → 要約 → Notion → Archive → LINE
 *    (X/TwitterならPocketから削除してスキップ)
 *    (403エラー記事はPocketをアーカイブ＆Notionに「403エラー」ページを作る)
 */
function summarizeAndSave() {
  // 前回の実行時刻 (UnixTime) を取得。未設定なら1週間前をデフォルトに
  const scriptProperties = PropertiesService.getScriptProperties();
  const lastRun = scriptProperties.getProperty('lastRun');
  const oneWeekAgo = Math.floor(Date.now() / 1000) - (60 * 60 * 24 * 7);
  const since = lastRun && parseInt(lastRun) > oneWeekAgo ? parseInt(lastRun) : oneWeekAgo;

  // Pocket から (itemId, url) を取得
  const pocketArticles = getPocketArticlesWithId(since);

  // 今の日時を記録
  const now = new Date();
  const executedAt = Utilities.formatDate(now, 'Asia/Tokyo', 'yyyy-MM-dd HH:mm:ss');

  // LINE通知用にまとめる配列
  const results = [];

  // Pocketから取得した記事を順に処理
  for (const article of pocketArticles) {
    const { itemId, url } = article;

    // x.com or twitter.com は完全に削除してスキップ
    if (url.includes('x.com') || url.includes('twitter.com')) {
      Logger.log(`Deleting X/Twitter from Pocket: ${url}`);
      deletePocketItem(itemId);
      continue;
    }

    // すでに Notion に同じ URL があるかチェック
    if (hasDuplicateInNotion(url)) {
      Logger.log(`Skipping duplicate URL in Notion: ${url}`);
      archivePocketItem(itemId); // 重複でも Pocket 側をアーカイブ化
      continue;
    }

    try {
      // 1) 記事の本文抽出
      const { title, bodyContent } = extractArticleContent(url);

      // 2) Gemini で要約
      const { summary, comment } = summarizeWithGemini(bodyContent);

      // 3) Notion に保存
      saveToNotion(title, url, summary, comment, executedAt);

      // 4) Pocket 側をアーカイブ（既読化）
      archivePocketItem(itemId);

      // 5) LINE通知のための結果を配列に格納
      results.push({ title, url, summary, comment });

    } catch (error) {
      // 403エラーなら特殊処理
      if (error.message.includes('403')) {
        Logger.log(`403エラーで本文取得不可: ${url}`);

        // Notionに403エラーとして簡易ページを保存
        const errorTitle = `(403エラー) ${url}`;
        const errorSummary = '403エラーで本文取得不可';
        const errorComment = '';  // 必要に応じてメモを追加してもOK

        saveToNotion(errorTitle, url, errorSummary, errorComment, executedAt);

        // Pocket 側は既読化して、再度取得対象にしない
        archivePocketItem(itemId);

        // LINE通知には載せず、次の記事へ
        continue;
      } else {
        // その他のエラーは従来どおりログを残してスキップ
        logError(`Error processing ${url}`, error);
        // エラー時にPocketをどうするかは運用次第（ここでアーカイブしてもよい）
        continue;
      }
    }
  }

  // 新しい記事が1件以上あれば、LINEにまとめて通知
  if (results.length > 0) {
    try {
      sendFlexMessageBatch(results);
    } catch (error) {
      logError(`Error sending LINE batch notification`, error);
    }
  }

  // 今回実行時刻を保存 (次回実行時の since に利用)
  const nowUnixTime = Math.floor(Date.now() / 1000);
  scriptProperties.setProperty('lastRun', nowUnixTime);
}

/*******************************************************
 *  11) 環境変数＆ログ出力
 *******************************************************/
/**
 * 環境変数を取得するヘルパー関数
 */
function getEnvironmentVariable(key) {
  const value = PropertiesService.getScriptProperties().getProperty(key);
  if (!value) {
    throw new Error(`Environment variable not set: ${key}`);
  }
  return value;
}

/**
 * エラーログを出力する関数
 */
function logError(message, context = {}) {
  console.error(message);
  if (Object.keys(context).length > 0) {
    console.error(JSON.stringify(context, null, 2));
  }
}
```

</details>

## おわりに

この記事では、Xの「いいね」から情報収集・要約・蓄積・通知までを**完全自動化**する仕組みを構築しました。

NotionやLINEに限らず、SlackやDiscord、Gmailなどへカスタマイズすることで、より自分に合った知識インプットパイプラインが作れます。

> 📣「自分はこうしてる」「こう変えたら便利だった」などのコメントもぜひお待ちしています！