1. はじめに
この記事では、Google Apps Script(GAS)を使用して、Pocketに保存した記事を自動で要約し、LINEに通知するスクリプトを紹介します。さらに、このスクリプトを5分おきに自動実行するための設定方法についても解説します。
Pocket に保存しておくと、下記のような要約がLINEに飛んでくるので、興味のある記事の要点を手軽に把握したり、細かく読むべきかの判断ができるようになります。
2. 必要なAPIと準備
このスクリプトを実行する前に、以下の準備が必要です。細かい説明はしません。
-
Pocket APIの認証:
-
Pocket Developerでアプリケーションを作成し、
consumer_key
とaccess_token
を取得します。
-
Pocket Developerでアプリケーションを作成し、
-
Gemini APIの認証:
- Google Gemini APIキーを取得します。
-
LINE Messaging APIの認証:
- LINE Developersコンソールでプロバイダーとチャネルを作成し、チャネルアクセストークンとLINEユーザーIDを取得します。
-
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分おき実行設定
- GASエディタにスクリプトをコピー&ペーストします。
- 環境変数を設定します。
-
summarizeAndSave
関数を選択し、一度実行ボタンをクリックします。(初回実行時はGASの権限許可が求められるので、許可してください。) -
5分おきに自動実行するためのトリガーを設定します。
- GASエディタの左側にある時計のようなアイコン(トリガー)をクリックします。
- トリガーの管理画面が開くので、右下にある「トリガーを追加」ボタンをクリックします。
- トリガーの設定を以下のようにします。
-
実行する関数:
summarizeAndSave
を選択します。 -
実行するデプロイ:
Head
(通常はこれでOK)を選択します。 - イベントソース: 「時間主導型」を選択します。
- 時間ベースのトリガーのタイプ: 「分タイマー」を選択します。
- 間隔(分): 「5分おき」を選択します。
- エラー通知の設定: 必要に応じて設定します(エラー発生時にメール通知を受け取るなど)。
-
実行する関数:
- 設定内容を確認し、「保存」ボタンをクリックします。
これで、summarizeAndSave
関数が5分おきに自動で実行されるようになります。
6. まとめ
この記事では、Pocketの記事を自動で要約し、LINEに通知するGASスクリプトについて解説しました。さらに、このスクリプトを5分おきに自動実行するための設定方法についても説明しました。このスクリプトを活用することで、Pocketに溜まった記事を効率的に処理し、情報収集の効率化を図ることができます。
7. 参考資料
- Pocket API: https://getpocket.com/developer/
- Gemini API: https://ai.google.dev/
- LINE Messaging API: https://developers.line.biz/ja/
- Google Apps Script: https://developers.google.com/apps-script