2
4

Google Scholarで検索した内容をChatGPTで要約する (2/2)

Last updated at Posted at 2023-04-10

前回の記事の続き。

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のメールが
image.png
以下のようなメールに変換されて自分自身にメールが送られてくる。
image.png

リンク先がpdfでも問題無く要約が作成される。このメールの場合、消費tokenは892 tokensだった(203 prompt + 689 completion、1000 tokenで$0.002)。

このscriptを動かしていくと、次のようなGoogle SpreadSheetができる。
image.png

この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の先をちゃんと読むように改変

2
4
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
2
4