前回の記事の続き。
Google Scholarは自分の興味のあるキーワードの最新の記事をE-mail送ってくれる機能であるalert機能がある。既に自分の興味のある論文のalertは設定済みなのだから、これを利用するのも手だ。私の場合、alertメールがかなりGmailの受信箱にたまっているので、これを整理したいというのもある。
Gmailで受信している場合限定になるが、以下のスクリプトはGmailの受信箱にあるGoogle Scholar Alertのメールをランダムにピックアップして、ChatGPTの要約をつけて、自分宛にメールを送信する。もとのGoogle Scholar Alertのメールは既読にし、アーカイブする。処理した論文はGoogle SpreadSheetに記録する。この記録は一度処理した論文のデータベースにもなるし、同じ論文を2度処理する無駄を省くのにも使う。
//*** Constants*****
// number of many mail to process
const MAX_MAIL_COUNT = 1;
// avoid process if number of URLs is more than MAX_URLS_TO_ABOID_TIMEOUT
const MAX_URLS_TO_ABOID_TIMEOUT = 3
// query for seaching gmails
const QUERY = 'from:scholaralerts-noreply@google.com label:inbox';
//
const OPENAI_API_KEY = "YOUR API KEY";
const PROMPT_PREFIX = "あなたは気候学者。以下の論文を、日本語で説明して。要点は箇条書きで。";
//
const EMAIL_RECIPIENT = "your@emailadress.com";
const EMAIL_SUBJECT = "Google Scholar Mail 要約 ";
const EMAIL_SENDER = "要約bot";
//
const FOLDER_ID = ''; // Optional: Set the folder ID where you want to create the sheet
const SHEET_NAME = 'GoogleScholarAlerts';
// Main function that processes the emails
function main() {
// Find random threads based on the query
const randomThreads = findRandomThreads();
// Get or create the Google Sheet
let sheet = getOrCreateSheet(SHEET_NAME);
// Process each thread
for (const randomThread of randomThreads) {
const randomEmail = randomThread.getMessages()[0];
// Log the email details
Logger.log("Randomly selected email:\nSubject: %s\nDate: %s\nID: %s",
randomEmail.getSubject(), randomEmail.getDate(), randomEmail.getId());
// Prepare the output email content
var output = "論文要約のお知らせ\n\n";
const articles = parseGoogleScholarAlertEmail(randomEmail.getBody());
// Process each article in the email
if (articles.length<=MAX_URLS_TO_ABOID_TIMEOUT){
for (const article of articles) {
const isDuplicate = appendRowIfNotDuplicate(sheet, randomEmail,article);
if (isDuplicate) {
Logger.log('Duplicate article found: %s', article.title);
} else {
Logger.log('Article appended: %s', article.title);
const content = fetchURLContent(article.url);
if (!content) return null;
const cleanContent = removeUnnecessaryTags(content);
Logger.log(cleanContent)
const input = "content: " + cleanContent + " entitled " + article.title;
const res = callChatGPT(input);
const paragraphs = res.choices.map((c) => c.message.content.trim());
appendRowWithResponse(sheet, article, paragraphs.join("\n"));
// Build the output email content
output += "title: " + article.title + "\n" +
"authors: " + article.authors + "\n" +
"publication: " + article.publication + "\n" +
"url: " + article.url + "\n" +
"snippet: " + article.snippet + "\n\n" +
"ChatGPT says: " + `${paragraphs.join("\n")}` + "\n\n\n\n";
}
}
// Send the summary email
// Mark the email as read and archive it
randomThread.markRead();
randomThread.moveToArchive();
}
else {
output = "処理しませんでした。\n number urls > MAX_URLS_TO_ABOID_TIMEOUT"
}
// Send the summary email
sendEmail(randomEmail.getSubject() + " " + randomEmail.getDate(), output);
}
}
// ---------------------
// Function to get or create the Google Sheet
function getOrCreateSheet(sheetName) {
const files = DriveApp.searchFiles(`title="${sheetName}" and mimeType="application/vnd.google-apps.spreadsheet"`);
let sheet;
// If the sheet exists, open it, otherwise create a new one
if (files.hasNext()) {
const file = files.next();
const spreadsheet = SpreadsheetApp.openById(file.getId());
sheet =spreadsheet.getSheets()[0];
} else {
const spreadsheet = SpreadsheetApp.create(sheetName);
if (FOLDER_ID) {
const file = DriveApp.getFileById(spreadsheet.getId());
file.moveTo(DriveApp.getFolderById(FOLDER_ID));
}
sheet = spreadsheet.getSheets()[0];
sheet.appendRow(['Email subject', 'E-mail date','Title', 'Authors', 'Publication', 'Snippet', 'URL', "ChatGPT says"]);
}
return sheet;
}
// ---------------------
// Function to append a row to the sheet if the article is not a duplicate
function appendRowIfNotDuplicate(sheet, email,article) {
const lastRow = sheet.getLastRow();
Logger.log(lastRow);
const data = sheet.getRange(1, 1, lastRow, 7).getValues();
const isDuplicate = data.some(row => row[6] === article.url);
if (!isDuplicate) {
sheet.appendRow([
email.getSubject(),
email.getDate(),
article.title,
article.authors,
article.publication,
article.snippet,
article.url
]);
}
return isDuplicate;
}
// ---------------------
// Function to append the ChatGPT response to the same row with the same URL
function appendRowWithResponse(sheet, article, response) {
const lastRow = sheet.getLastRow();
const data = sheet.getRange(1, 1, lastRow, 7).getValues();
let rowExists = false;
for (let i = 0; i < data.length; i++) {
if (data[i][6] === article.url) {
rowExists = true;
sheet.getRange(i + 1, 8).setValue(response);
break;
}
}
}
// ---------------------
// Function to find random threads based on the query
function findRandomThreads() {
const threads = GmailApp.search(QUERY);
const randomThreads = [];
if (threads.length === 0) {
Logger.log("No emails found with the specified query.");
return [];
}
for (let i = 0; i < MAX_MAIL_COUNT; i++) {
if (i > threads.length) break;
const randomIndex = Math.floor(Math.random() * threads.length);
const randomThread = threads.splice(randomIndex, 1)[0];
randomThreads.push(randomThread);
}
return randomThreads;
}
// ---------------------
// Function to parse Google Scholar Alert email content
function parseGoogleScholarAlertEmail(emailContent) {
const results = [];
// Regular expressions to extract various parts of the email
const titleRegex = /<a href[^>]*class="gse_alrt_title"[^>]*>(.*?)<\/a>/g;
const authorsRegex = /<div style="color:#006621;line-height:18px">(.*?)<\/div>/g;
const publicationRegex = /- (.*?)<\/div>/g;
const snippetRegex = /<div class="gse_alrt_sni"[^>]*>([\s\S]*?)<\/div>/g;
const urlRegex = /<a href="https:\/\/scholar\.google\.com\/scholar_url\?url=([^"]*)"[^>]*class="gse_alrt_title"/g;
const urlRegexJp = /<a href="https:\/\/scholar\.google\.co.\jp\/scholar_url\?url=([^"]*)"[^>]*class="gse_alrt_title"/g;
// Extract the information from the email
while (true) {
const titleMatch = titleRegex.exec(emailContent);
const authorsMatch = authorsRegex.exec(emailContent);
const publicationMatch = publicationRegex.exec(emailContent);
const snippetMatch = snippetRegex.exec(emailContent);
var urlMatch = urlRegex.exec(emailContent);
if (!titleMatch) break;
if (titleMatch[1] == "See all recommendations") break;
if (!urlMatch) urlMatch = urlRegexJp.exec(emailContent);
const result = {
title: titleMatch[1],
authors: authorsMatch ? authorsMatch[1].split('-')[0] : '',
publication: publicationMatch ? publicationMatch[1] : '',
snippet: snippetMatch ? snippetMatch[1].replace(/<br>/g, ' ').replace(/<b>/g, '').replace(/<\/b>/g, '').trim() : '',
url: urlMatch ? decodeURIComponent(urlMatch[1]).split('&')[0] : ''
};
results.push(result);
}
return results;
}
// ---------------------
// Function to call the ChatGPT API
function callChatGPT(input) {
const messages = [
{
role: "user",
content: PROMPT_PREFIX + "\n" + input,
},
];
const url = "https://api.openai.com/v1/chat/completions";
const options = {
"method": "post",
"headers": {
"Authorization": `Bearer ${OPENAI_API_KEY}`,
"Content-Type": "application/json",
},
"payload": JSON.stringify({
model: "gpt-3.5-turbo",
messages,
}),
};
return JSON.parse(UrlFetchApp.fetch(url, options).getContentText());
}
// ---------------------
// Function to send an email with the summary of the articles
function sendEmail(subject, body) {
const options = { name: EMAIL_SENDER };
GmailApp.sendEmail(EMAIL_RECIPIENT, EMAIL_SUBJECT + subject, body, options);
}
//
function removeUnnecessaryTags(content) {
// Remove script and style tags and their content
content = content.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '');
content = content.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '');
// Remove other HTML tags
content = content.replace(/<\/?[^>]+(>|$)/g, "");
content = content.replace(/\s+/g, ' ').trim()
return content;
}
//
function fetchURLContent(url) {
try {
const response = UrlFetchApp.fetch(url);
const content = response.getContentText();
return content;
} catch (e) {
Logger.log('Error fetching URL content: ' + e.message);
return null;
}
}
ChatGPTにscriptを解説させると以下の通り:
このスクリプトは、Google Apps Scriptを使用して、Google Scholarからのメールを処理して、要約を作成し、それをGoogleシートに保存し、要約メールを作成して送信するプログラムです。以下に、各部分の詳細を説明します。
定数部分
MAX_MAIL_COUNT: 処理するメールの最大数。1と設定されています。
MAX_URLS_TO_ABOID_TIMEOUT: タイムアウトを回避するために、URLの数がこの定数よりも多い場合は、処理を避けるためにメールを処理しないようにします。3と設定されています。
QUERY: Gmailのクエリ。このスクリプトでは、Scholar Alertsからのメールを処理するために、ラベル「inbox」の付いた、送信元が「scholaralerts-noreply@google.com」のメールを処理するように指定されています。
OPENAI_API_KEY: OpenAIのChatGPT APIを呼び出すためのAPIキー。
PROMPT_PREFIX: ChatGPT APIに与えるプロンプトのプレフィックス。このプログラムでは、「あなたは気候学者。以下の論文を、日本語で説明して。要点は箇条書きで。」というプレフィックスが与えられています。
EMAIL_RECIPIENT: 要約メールを送信するメールアドレス。
EMAIL_SUBJECT: 要約メールの件名。Google Scholar Mail 要約 と設定されています。
EMAIL_SENDER: 要約メールの送信者。要約botと設定されています。
FOLDER_ID: Googleシートを作成するフォルダーのID。
SHEET_NAME: Googleシートの名前。GoogleScholarAlertsと設定されています。メイン関数(main())
ランダムなスレッドを取得する(findRandomThreads()関数を使用)
Googleシートを取得または作成する(getOrCreateSheet()関数を使用)
各スレッドを処理するためのループを実行する
スレッド内の最初のメールを取得し、その詳細をログに出力する。
メールの本文を解析して、要約を作成する(parseGoogleScholarAlertEmail()関数を使用)
各論文について処理を行う
URLの数が許容範囲内である場合、論文の情報をGoogleシートに追加する(appendRowIfNotDuplicate()関数を使用)
論文のURLが重複している場合は、処理をスキップする。
論文の情報をもとに、ChatGPT APIを呼び出して、論文の要約を生成する(callChatGPT()関数を使用)
生成された要約をGoogleシートに追加する(appendRowWithResponse()関数を使用)
要約メールの本文を作成する
メールを既読にし、アーカイブする
処理したメールの件名と日付をもとに、要約メールの件名を作成する
要約メールを送信する(sendEmail()関数を使用)関数の詳細:
getOrCreateSheet(): 指定された名前のGoogleシートを検索し、存在しない場合は作成する。その後、Googleシートの最初のシートを取得する。
appendRowIfNotDuplicate(): シート内に重複するURLがある場合は、その行を追加しない。重複していない場合は、シートの最後に新しい行を追加する。
appendRowWithResponse(): 与えられたURLと一致する行に、ChatGPTからの応答を追加する。
findRandomThreads(): Gmailのクエリを使用して、指定された条件に一致するランダムなスレッドを取得する。
parseGoogleScholarAlertEmail(): Google Scholar Alertからのメールの本文を解析して、論文の情報を抽出する。
callChatGPT(): OpenAIのChatGPT APIを呼び出し、指定されたプロンプトと入力を使用して、要約を生成する。
sendEmail(): 指定された件名、本文、および送信先アドレスを使用して、要約メールを送信する。このプログラムを使用することで、Google Scholarからのメールを処理して、要約を自動的に作成し、Googleシートに保存し、要約メールを送信することができます。また、ChatGPT APIを使用することで、要約の品質を向上させることができます。
このスクリプトで以下のようなGoogle Sholar Alertのメールが
以下のようなメールに変換されて自分自身にメールが送られてくる。
リンク先がpdfでも問題無く要約が作成される。このメールの場合、消費tokenは892 tokensだった(203 prompt + 689 completion、1000 tokenで$0.002)。
このscriptを動かしていくと、次のようなGoogle SpreadSheetができる。
このscriptではMAX_MAIL_COUNTを1以上にすると、1度に複数のメールを処理することができる。ただ、たくさんのメールを処理しようとするとGoogle Apps Scriptの制限時間6分に引っかかりやすい(ChatGPTから反応が返ってくるのに時間がかかる)。例えばMAX_MAIL_COUNT=3として1日に一回scriptをトリガーするよりも、MAX_MAIL_COUNT=1として、1日3回トリガーするほうが無難だ。
同じく制限時間という意味では、1つのメールにたくさんのURLが含まれていると、時間切れになりやすい。そこで、MAX_URLS_TO_ABOID_TIMEOUTより多いURLが含まれるメールは処理しないようにしている。この数はChatGPTがどれだけ時間がかかるかによって調節する必要がある。
このscriptではinboxの中のメールを処理しているが、QUERYを変えれば、別のラベルがついたメールでも処理できる。別のラベルを付け替えるようにscriptを書き換えることもできるだろう(ラベルの書き換えについては「ラベルを自動的に付けたり外したりするGoogle Apps Script」を参照)。
最後に。このscriptは、こんなことがしたいんだけどとChatGPTと相談しながら書いた。そのまま動いたわけではないけれども、私のようなGoogle Apps ScriptやJavaScriptの初心者でも、分からないことは相談しながらscriptを作ることができた。論文の要約できることよりも、いままで出来なかったプログラミングを手伝ってくれるChatGPTの能力に一番感心している。
2023/04/13
function getOrCreateSheetを修正
2024/05/15
urlの先をちゃんと読むように改変