GAS + Gemini API で Pocket に保存した記事を自動で要約してLINE通知

### 1. はじめに

この記事では、Google Apps Script（GAS）を使用して、Pocketに保存した記事を自動で要約し、LINEに通知するスクリプトを紹介します。さらに、このスクリプトを5分おきに自動実行するための設定方法についても解説します。

Pocket に保存しておくと、下記のような要約がLINEに飛んでくるので、興味のある記事の要点を手軽に把握したり、細かく読むべきかの判断ができるようになります。

![Screenshot_20250108-104614~2 (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184983/1051f609-e6c6-2198-a866-4fd23f5e34c0.png)

### 2. 必要なAPIと準備

このスクリプトを実行する前に、以下の準備が必要です。細かい説明はしません。

1.  **Pocket APIの認証:**
    -   [Pocket Developer](https://getpocket.com/developer/)でアプリケーションを作成し、`consumer_key`と`access_token`を取得します。
2.  **Gemini APIの認証:**
    -   Google Gemini APIキーを取得します。
3.  **LINE Messaging APIの認証:**
    -   LINE Developersコンソールでプロバイダーとチャネルを作成し、チャネルアクセストークンとLINEユーザーIDを取得します。
4.  **Googleスプレッドシートの準備:**
    -   要約結果を保存するGoogleスプレッドシートを作成し、スプレッドシートIDとシート名を取得します。

これらの情報は、後ほどGASのスクリプトプロパティに環境変数として設定します。

### 3. スクリプトの全体構成

```javascript
/**
 * Pocketから記事のURLを取得する関数
 * @param {number} since 前回の実行時刻（UNIXタイムスタンプ、秒単位）
 * @returns {Array} 取得した記事のURLの配列。
 * @throws {Error} Pocket APIとの通信に失敗した場合、またはエラーレスポンスを受け取った場合にエラーをスローする。
 */
function getPocketArticles(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', // タイトルとURLのみ
      'since': since      // 前回実行以降に追加された記事
    }),
    'muteHttpExceptions': true
  };

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

  if (responseCode !== 200) {
    // HTTPエラーの場合
    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) {
    // Pocket APIがエラーを返した場合
    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}`);
  }

  // 記事URLの配列を返す
  return Object.values(json.list || {}).map(article => article.resolved_url);
}

/**
 * URLから記事の本文を抽出する関数
 * @param {string} url 記事のURL
 * @returns {Object} 抽出した記事のタイトルと本文を含むオブジェクト {title, bodyContent}
 * @throws {Error} URLから本文の取得に失敗した場合にエラーをスローする。
 */
function extractArticleContent(url) {
  try {
    const response = UrlFetchApp.fetch(url);
    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;

    // 簡易的なHTMLパース
    const bodyStart = html.indexOf('<body');
    const bodyEnd = html.indexOf('</body>', bodyStart);
    let bodyContent = html.substring(bodyStart, bodyEnd);

    // タグ除去
    bodyContent = bodyContent.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, ''); // スクリプトタグ除去
    bodyContent = bodyContent.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, ''); // スタイルタグ除去
    bodyContent = bodyContent.replace(/<[^>]+>/g, ''); // 他のHTMLタグをすべて除去
    bodyContent = bodyContent.replace(/ /g, ' '); //  をスペースに変換
    bodyContent = bodyContent.replace(/\s+/g, ' ').trim(); // 余分な空白を削除

    return {title, bodyContent};
  } catch (error) {
    logError(`Error extracting content from ${url}`, error);
    throw new Error(`Error extracting content from ${url}: ${error.message}`);
  }
}

/**
 * Gemini API を使って記事を要約する関数
 * @param {string} text 要約する記事の本文
 * @returns {Object} 記事の要約とコメントを含むオブジェクト {summary, comment}
 * @throws {Error} 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形式で出力してください。

【手順】
1. テキスト全体を読み、筆者の主張、論理展開、主要な事実、結論、そして可能であれば筆者の意図を把握してください。文体やレトリックは内容理解のために参照し、要約やまとめには反映させないでください。
2. 要約作成: テキストの内容を日本語で100字以内で箇条書きで要約してください。
3. コメント作成: なぜこの記事を読むべきかについてコメントをしてください

【入力テキスト】
${text}`,
      }],
    }],
    "generationConfig": {
      "temperature": 0.0,
      "maxOutputTokens": 512,
      "responseMimeType": "application/json",
      "response_schema": {
        "type": "OBJECT",
        "properties": {
          "summary": {
            "type": "STRING",
            "maxLength": 200,
            "description": "日本語で100字以内で箇条書きで要約"
          },
          "comment": {
            "type": "STRING",
            "maxLength": 150, 
            "description": "なぜこの記事を読むべきかについてコメント"
          }
        },
        "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());

  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');
  }
}

/**
 * Flex Message を使用して LINE に記事情報を送信する関数
 * @param {string} title 記事のタイトル
 * @param {string} url 記事のURL
 * @param {string} summary 記事の要約
 * @param {string} comment 記事へのコメント
 * @param {string} [accessToken] LINE Messaging API のチャネルアクセストークン（オプション、指定しない場合は環境変数から取得）
 * @param {string} [to] 送信先ユーザーの LINE ユーザー ID（オプション、指定しない場合は環境変数から取得）
 * @throws {Error} LINE Messaging API との通信に失敗した場合、またはエラーレスポンスを受け取った場合にエラーをスローする。
 */
function sendFlexMessage(title, url, summary, comment, accessToken = null, to = null) {
  const lineAccessToken = accessToken || getEnvironmentVariable('LINE_CHANNEL_ACCESS_TOKEN');
  const destinationId = to || getEnvironmentVariable('LINE_USER_ID');

  const options = {
    'method': 'post',
    'headers': {
      'Authorization': `Bearer ${lineAccessToken}`,
      'Content-Type': 'application/json'
    },
    'payload': JSON.stringify({
      'to': destinationId,
      'messages': [
        {
          'type': 'flex',
          'altText': '記事要約', // 代替テキスト（Flex Messageをサポートしていない環境で表示される）
          'contents': {
            'type': 'bubble',
            'size': 'giga',
            'body': {
              'type': 'box',
              'layout': 'vertical',
              'contents': [
                {
                  'type': 'text',
                  'text': title,
                  'weight': 'bold',
                  'size': 'sm',
                  'wrap': true,
                  'margin': 'none',
                  "color": "#0000FF", // リンク色に変更 (青)
                  "decoration": "underline", // 下線を引く
                  'action': {  // タイトルにアクションを追加
                    'type': 'uri',
                    'label': 'web',
                    'uri': url
                  }
                },
                {
                  'type': 'separator',
                  'margin': 'lg'
                },
                {
                  'type': 'box',
                  'layout': 'vertical',
                  'margin': 'lg',
                  'spacing': 'sm',
                  'contents': [
                    {
                      'type': 'text',
                      'text': summary,
                      'wrap': true,
                      'size': 'sm'
                    }
                  ]
                },
                {
                  'type': 'box',
                  'layout': 'vertical',
                  'margin': 'lg',
                  'spacing': 'sm',
                  'contents': [
                    {
                      'type': 'text',
                      'text': comment,
                      'wrap': true,
                      'size': 'sm'
                    }
                  ]
                }
              ]
            }
          }
        }
      ]
    }),
    '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}`);
  }
}

/**
 * 要約をGoogleスプレッドシートに保存し、LINEに通知する関数
 * @param {string} title 記事のタイトル
 * @param {string} url 記事のURL
 * @param {string} summary 記事の要約
 * @param {string} comment 記事へのコメント
 * @param {string} executedAt 実行日時
 */
function saveToSpreadsheet(title, url, summary, comment, executedAt) {
  // 機密情報を環境変数から取得
  const spreadsheetId = getEnvironmentVariable('SPREADSHEET_ID');
  const sheetName = getEnvironmentVariable('SHEET_NAME');

  const spreadsheet = SpreadsheetApp.openById(spreadsheetId);
  const sheet = spreadsheet.getSheetByName(sheetName);
  // 最終行に追加（実行日時を追加）
  sheet.appendRow([title, url, summary, comment, executedAt]);

  // LINEに通知
  try {
    sendFlexMessage(title, url, summary, comment);
  } catch (error) {
    logError(`Error sending LINE notification for ${url}`, error);
  }
}

/**
 * メイン関数：Pocketから記事を取得し、要約してGoogleスプレッドシートに保存する
 */
function summarizeAndSave() {
  // 前回の実行時刻を取得
  const scriptProperties = PropertiesService.getScriptProperties();
  const lastRun = scriptProperties.getProperty('lastRun');  
  const oneWeekAgo = Math.floor(Date.now() / 1000) - (60 * 60 * 24 * 7); // 1週間前
  const since = lastRun && parseInt(lastRun) > oneWeekAgo ? parseInt(lastRun) : oneWeekAgo;

  // Pocket から記事URLを取得
  const urls = getPocketArticles(since);

  // ★実行日時を取得
  const now = new Date();
  const executedAt = Utilities.formatDate(now, 'Asia/Tokyo', 'yyyy-MM-dd HH:mm:ss');

  // ★スプレッドシートから既存のURLを取得
  const spreadsheetId = getEnvironmentVariable('SPREADSHEET_ID');
  const sheetName = getEnvironmentVariable('SHEET_NAME');
  const spreadsheet = SpreadsheetApp.openById(spreadsheetId);
  const sheet = spreadsheet.getSheetByName(sheetName);
  const lastRow = sheet.getLastRow();
  const existingUrls = lastRow > 1 ? sheet.getRange(2, 2, lastRow - 1).getValues().flat() : [];

  // 各記事を要約し、スプレッドシートに保存
  for (const url of urls) {
    try {
      // ★重複チェック：スプレッドシートに既に同じURLが存在する場合はスキップ
      if (existingUrls.includes(url)) {
        console.log(`Skipping already processed URL: ${url}`);
        continue;
      }

      const {title, bodyContent} = extractArticleContent(url);
      const {summary, comment} = summarizeWithGemini(bodyContent);
      saveToSpreadsheet(title, url, summary, comment, executedAt);
    } catch (error) {
      logError(`Error processing ${url}`, error);
    }
  }

  // 実行時刻を記録
  const nowUnixTime = Math.floor(Date.now() / 1000);
  scriptProperties.setProperty('lastRun', nowUnixTime);
}

/**
 * 環境変数を取得する関数
 * @param {string} key 環境変数のキー
 * @returns {string} 環境変数の値
 * @throws {Error} 環境変数が設定されていない場合にエラーをスローする。
 */
function getEnvironmentVariable(key) {
  const value = PropertiesService.getScriptProperties().getProperty(key);
  if (!value) {
    throw new Error(`Environment variable not set: ${key}`);
  }
  return value;
}

/**
 * エラーログを出力する関数
 * @param {string} message エラーメッセージ
 * @param {object} [context] エラーに関連する追加情報（オプション）
 */
function logError(message, context = {}) {
  console.error(message);
  if (Object.keys(context).length > 0) {
    console.error(JSON.stringify(context, null, 2));
  }
}
```

### 4. 環境変数の設定

GASのエディタで、「ファイル」→「プロジェクトのプロパティ」→「スクリプトのプロパティ」を選択し、以下の環境変数を設定してください。

| キー                          | 説明                              |
| --------------------------- | ------------------------------- |
| `POCKET_CONSUMER_KEY`       | Pocket APIのコンシューマーキー            |
| `POCKET_ACCESS_TOKEN`       | Pocket APIのアクセストークン             |
| `GEMINI_API_KEY`            | Gemini APIのAPIキー                |
| `MODEL_NAME`                | Gemini APIのモデル名（例: gemini-pro）  |
| `LINE_CHANNEL_ACCESS_TOKEN` | LINE Messaging APIのチャネルアクセストークン |
| `LINE_USER_ID`              | 送信先のLINEユーザーID                  |
| `SPREADSHEET_ID`            | GoogleスプレッドシートのID               |
| `SHEET_NAME`                | Googleスプレッドシートのシート名             |

### 5. スクリプトの実行方法と5分おき実行設定

1.  **GASエディタにスクリプトをコピー＆ペーストします。**
2.  **環境変数を設定します。**
3.  **`summarizeAndSave`関数を選択し、一度実行ボタンをクリックします。**（初回実行時はGASの権限許可が求められるので、許可してください。）
4.  **5分おきに自動実行するためのトリガーを設定します。**
    -   GASエディタの左側にある時計のようなアイコン（トリガー）をクリックします。
    -   トリガーの管理画面が開くので、右下にある「トリガーを追加」ボタンをクリックします。
    -   トリガーの設定を以下のようにします。
        -   **実行する関数:** `summarizeAndSave` を選択します。
        -   **実行するデプロイ:** `Head`（通常はこれでOK）を選択します。
        -   **イベントソース:** 「時間主導型」を選択します。
        -   **時間ベースのトリガーのタイプ:** 「分タイマー」を選択します。
        -   **間隔（分）:** 「5分おき」を選択します。
        -   **エラー通知の設定:** 必要に応じて設定します（エラー発生時にメール通知を受け取るなど）。
    -   設定内容を確認し、「保存」ボタンをクリックします。

これで、`summarizeAndSave` 関数が5分おきに自動で実行されるようになります。


### 6. まとめ

この記事では、Pocketの記事を自動で要約し、LINEに通知するGASスクリプトについて解説しました。さらに、このスクリプトを5分おきに自動実行するための設定方法についても説明しました。このスクリプトを活用することで、Pocketに溜まった記事を効率的に処理し、情報収集の効率化を図ることができます。

### 7. 参考資料

-   Pocket API: [https://getpocket.com/developer/](https://getpocket.com/developer/)
-   Gemini API: [https://ai.google.dev/](https://ai.google.dev/)
-   LINE Messaging API: [https://developers.line.biz/ja/](https://developers.line.biz/ja/)
-   Google Apps Script: [https://developers.google.com/apps-script](https://developers.google.com/apps-script)