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

きっかけはこの記事である。
> 話題の「ChatGPT」で新着論文を要約し、毎朝レポートメールを自動送付する仕組みを公表　当サイトコラムニストの内田医師 | Med IT Tech
> https://medit.tech/dr-uchida-chatgpt-2023/

Google Apps Scriptを使った仕組みである。自分も取り入れてみたいと思ったものの、新着論文を探すのに使われているのはPubMedである。私の研究分野(海洋学、気候学)だと文献が十分に探せない。そこでPubMedの部分をGoogle Scholarに置き換えてみた。

以下がscriptである。どのようにGoogle Apps Scriptを使うかについては上記の記事に引用されているツイートに詳しい。
```
// APK keys
const OPENAI_API_KEY = "YOUR API KEY";
var SERPAPI_API_KEY = 'YOUR API KEY';
// 結果メールの送信先
const EMAIL_RECIPIENT = "your@emailadress.com";
// Key word
const keyword = '"marine heatwave" OR "marine heatwaves"';
// ChatGPT に渡す命令
const PROMPT_PREFIX = "あなたは気候学者。以下の論文を、日本語で説明して。要点は箇条書きで。";
// 結果メールのタイトル
const EMAIL_SUBJECT = `Google scholar 要約 (${keyword}) `;
// 結果メールの送信者の名前
const EMAIL_SENDER = "要約bot";
// 最新から検索対象日数
const DAYS = 3;
// ヒット論文で要約する論文の本数の上限（多ければ多いだけ、ChatGPT API の token を多く消費する）
const MAX_PAPER_COUNT = 2;

function main() {
  // call Google Scholar
  var results = getMostRecentArticleUrlSerpApi(keyword);
  if (results){
    var output = "新着論文のお知らせ\n\n";
    var paperCount = 0;
    for (let i = 0; i < results.length; i++) {
      var title = results[i].title;
      var url = results[i].link;
      var daysago = parseInt(results[i].snippet.split(" ")[0])
      Logger.log(url)
      Logger.log(daysago)
      if (daysago<=DAYS){
        // call ChatGpt
        paperCount += 1

        const content = fetchURLContent(url);
        if (!content) return null;
        const cleanContent = removeUnnecessaryTags(content);
    
        Logger.log(cleanContent)
        
        const input = "content: " +cleanContent + " entitled "+ title
        const res = callChatGPT(input);
        
        // send result by E-mail 
        
        const paragraphs = res.choices.map((c) => c.message.content.trim());
        output += `${daysago} days ago\n`+title +"\n"+`${paragraphs.join("\n")}\n` + url + "\n\n\n";
      }
      else{
        break
      }      
      if (paperCount == MAX_PAPER_COUNT) break;
    }
    if (paperCount ==0){
      output = "No new article \n "
    } 
  }
  else{
    output="No article found (check keyword) \n "
  }
  sendEmail(output);
};


function getMostRecentArticleUrlSerpApi(keyword) {
  
  var searchUrl = 'https://serpapi.com/search?q=' + encodeURIComponent(keyword) +
                  '&engine=google_scholar' +
                  '&scisbd=1' +
                  '&api_key=' + SERPAPI_API_KEY;
  
  var options = {
    'method': 'get',
    'muteHttpExceptions': true,
    'followRedirects': true
  };
  
  var response = UrlFetchApp.fetch(searchUrl, options);
  var content = JSON.parse(response.getContentText());
  
  if (content.organic_results && content.organic_results.length > 0) {
    results=content.organic_results
    return results;
  } else {
    Logger.log("No articles found.");
    return null;
  }
}

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 sendEmail(body) {
    const options = { name: EMAIL_SENDER };
    GmailApp.sendEmail(EMAIL_RECIPIENT, EMAIL_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に前提抜きでこのコードを与えて、説明してくださいとお願いすると
> このコードは、Google Scholarで検索した最新の論文を取得し、ChatGPT APIを使用してその論文の要約を日本語で作成し、それをメールで送信するためのGoogle Apps Scriptです。以下に、コードの詳細を説明します。
>
>APIキーの設定
最初の数行は、ChatGPT APIとSerpApiのAPIキーを定義しています。これらのAPIキーを使用して、APIへのアクセスを許可します。
>
>メール設定の定義
メール設定の定義は、結果メールの送信先、送信者の名前、タイトルなどを含みます。結果の要約は、メールの本文に含まれます。
>
>検索キーワードの設定
>keyword変数は、Google Scholarで検索するキーワードを定義します。
>
>ChatGPTへの命令の設定
>ChatGPTに渡される命令は、PROMPT_PREFIX変数で定義されます。
>
>その他の設定
>DAYS変数は、最新から検索する日数を定義します。MAX_PAPER_COUNT変数は、要約する論文の数を制限するために使用されます。これらの設定は、ChatGPT APIの使用量を制限するために役立ちます。
>
>main()関数
>main()関数は、このスクリプトのエントリーポイントであり、以下の機能を提供します。
>
>Google Scholarで検索し、最新の論文を取得する
>ChatGPT APIを使用して、論文の要約を取得する
>論文のタイトル、日付、要約、URLを含むメールを作成する
>メールを送信する
>
>getMostRecentArticleUrlSerpApi()関数
>getMostRecentArticleUrlSerpApi()関数は、SerpApiを使用して、Google Scholarで検索し、最新の論文のURLを取得します。この関数は、keywordを引数として受け取ります。
>
>callChatGPT()関数
>callChatGPT()関数は、ChatGPT APIを呼び出し、論文の要約を取得します。この関数は、論文のタイトルとURLを含む入力を引数として受け取ります。
>
>sendEmail()関数
>sendEmail()関数は、メールを作成し、送信するためのGoogle Apps ScriptのGmailAppサービスを使用します。この関数は、メールの本文を引数として受け取ります。
>
>以上が、このGoogle Apps Scriptの詳細です。このスクリプトを実行するには、Googleアカウントが必要であり、Google Apps Scriptを実行するには、以下の手順に従います。
>
>Googleアカウントにログインします。
>Google Apps ScriptのWebエディタにアクセスします。
>新しいプロジェクトを作成し、コードをコピー＆ペーストします。
>スクリプトを実行するために必要なAPIキーを取得します。
>スクリプトの変数を編集し、必要に応じてカスタマイズします。
>main()関数を実行するトリガーを設定します。
>スクリプトの実行には、APIキーの取得が必要です。また、SerpApiの使用には有料プランが必要な場合があります。さらに、ChatGPT APIの使用には、OpenAIのアカウントが必要です。これらのAPIキーを取得するための手順は、それぞれのサービスのドキュメントに記載されています。
>
>このスクリプトを実行すると、Google Scholarで指定されたキーワードに基づいて、最新の論文のURLが取得されます。次に、ChatGPT APIを使用して、各論文の要約が作成されます。最後に、結果の要約が含まれるメールが送信されます。メールの本文には、論文のタイトル、日付、要約、およびURLが含まれます。
>
>このスクリプトは、研究者、学生、およびその他の関心のある人々にとって、最新の研究にアクセスし、研究に関する知識を得るための便利なツールとなります。

(ちゃんと説明してくれて、最後はヨイショまで、、、)

以後は自分で説明する。

Google Scholarには公式のAPIが無いので、3rd partyのSerpApi( https://serpapi.com/google-scholar-api )を使用した。Free Planは100 searches/monthなので、1日1回程度の検索であればFree Planの範囲内で十分問題無い。APIを取得して、スクリプト中のSERPAPI_API_KEYで指定する。

Google Scholarで論文出版年は指定出来るけれども、日付までは指定出来ないようなので、論文を最新の順に並び替えて(&scisbd=1)、”DAYS”以内の”MAX_PAPER_COUNT”個の論文の内容を処理している。これらの値を大きくするより多くの論文を処理してくれるがChatGPT API の token を多く消費することになる。

Google Scholarではabstractを情報として返してくれないので、URLとタイトルを放り込んで、要点を返してくれるようにお願いしている。そんな雑なお願いでもちゃんと答えは返してくれる。

以下が結果として送られてくるメールの例である。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/77672/bb5dc4d8-4ae4-524e-7455-8b108bcfc2e8.png)

このメールの作成例では912 tokensのChatGPTを消費している(1000 tokenで$0.002)。

理屈ではDAYS=1にして、このスクリプトを1日に1回トリガーすればよいはずだが、Google Scholarに反映されるのが遅れるためか、最新の論文が上記のメールのように2 days agoや3 days agoから始まることもあるようなので、DAYSをどう設定して、何日に一回トリガーさせるかは、どれだけ与えたキーワードの論文が出版されるかによって調整する必要があるだろう。

これで、もともとやりたいとこは出来るようになったのだが、上のようにどのような間隔で論文を検索させるか調整が必要な部分がある。できるだけもれなく検索したいし、逆に一度処理した論文は除外したい。また、他のキーワードでも検索させたいと思ったときに、SerpApiのFree Planでおさまらなくなるかもしれない。

そこで、別のアプローチとして、Google Sholarから普段からalertでメールが送られており、その受信箱にたまっているメールから情報を取りだして要約するというアプローチも考えて、scriptを作った。これに関してはまた[別記事](https://qiita.com/tmiyama/items/b58b2deb9b1ea709edee)で。

2024/5/15 url先の内容をちゃんと読むように改良。