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

[前回の記事](https://qiita.com/tmiyama/items/646595ac76dab039b6ec)の続き。

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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/77672/af4fe384-7b3c-aa8e-936d-3f18ad74783a.png)
以下のようなメールに変換されて自分自身にメールが送られてくる。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/77672/f7c6af09-0223-50e0-26b9-3f6e2673a550.png)

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

このscriptを動かしていくと、次のようなGoogle SpreadSheetができる。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/77672/1d77663f-3dea-7797-b11b-8c3a776712a9.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」を参照](https://qiita.com/tmiyama/items/3b0c8d05ba1a3ff1cd12))。

最後に。このscriptは、こんなことがしたいんだけどとChatGPTと相談しながら書いた。そのまま動いたわけではないけれども、私のようなGoogle Apps ScriptやJavaScriptの初心者でも、分からないことは相談しながらscriptを作ることができた。論文の要約できることよりも、いままで出来なかったプログラミングを手伝ってくれるChatGPTの能力に一番感心している。

_2023/04/13_
function getOrCreateSheetを修正

_2024/05/15_
urlの先をちゃんと読むように改変