0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2025-01-08

1. はじめに

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

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

Screenshot_20250108-104614~2 (1).png

2. 必要なAPIと準備

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

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

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

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

/**
 * 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. 参考資料

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?