研究メモブログの目次

【ChatGPT×Bluesky】PubMed から論文を自動取得&日本語要約するbot を作成してみた

こんにちは~、ガイです。

先週ついにChatGPT に課金しました!月3000 円の月謝は(安くない♪)ので、いろいろ使っていきたいと思っています。

第一弾として動物行動に関する論文情報を自動取得・要約・日本語訳するbot を作ってみました。

@nemunemu-nyanko.bsky.social on Bluesky

今回作ってみたbot の様子。「"Behavior, Animal"[Mesh] NOT "Humans"[Mesh] 」でPubMed で検索した論文のタイトルと要約を日本語で書きだし、英語タイトルと雑誌名略称とアブストの一部が入ったリンクカードの挿入を行います。それらの操作をすべて自動で行います。

作成したのはつい先日招待制が終了してユーザー爆増中のSNS「Bluesky」に投稿するbot です。Twitter のほうが使い慣れているのですが、API 利用が有料化してしまったので。

ChatGPT が何かについては皆さんご存知だと思うので説明は省きます。

準備するもの

これらの取得については検索すればいくらでもページが出てくるので、ここでの説明は省きます。

 

OpenAI のAPI keys の取得 

API(Application Programming Interface)はソフトウェアやプログラム、Webサービスの間をつなぐインターフェースのことで、API key とはAPI を使用するための認証情報です。

https://platform.openai.com/api-keys のページにアクセスし、+Create new secret key をクリックしてキーを発行します。名前は何でもいいです。大事なのはその次の「Save your key」ページに表示される文字列です。コピーして忘れない場所に保存しておいてください。文字列の保存後、Done でキーの完成です。

https://platform.openai.com/api-keys の画面

Save your key 画面

ここまでは以下のページを参考にしました。ありがとうございます。

ChatGPTによって指定した分野のPubMed論文を要約して毎日メールかLINEしてもらう方法 | 歯と口腔外科の役立つお話

Google Apps Script の用意

 

Google Apps Script(GAS)は、Googleが提供しているプログラミング言語で、Googleのさまざまなサービスと連携して利用できる便利なツールだそうです。わたしも今回初めて使いました。Googleアカウントがあれば無料で利用できます。開発環境の構築やツールのダウンロードは不要です。JavaScript の文法を踏襲しているみたいです。

Apps Script – Google Apps Script

自分はJavaScript を触ったことがないので、ネット上でほかの人が作ってくださった元のコードをChatGPT に編集してもらうことで今回のコードを作成しました。一応ChatGPT とbingAI にセキュリティ上問題がないコードかは確認してもらい、今のところ自分のほうでも不具合は出ていませんが、初心者が書いたコードなので実行は自己責任でお願いします。

何かこれまずいぞって箇所があったらコメントでぜひ教えてください!

参考にしたページ

Bluesky へのログイン処理とポストは以下のページを参考にしました。

GASでBlueskyのBotをつくった備忘録|けいが

GPT に送るプロンプトは以下のページを参考にしました。

論文をChatGPTに要約させて毎朝メールに通知を送ってもらうシステムの構築 #Python - Qiita

Bluesky の投稿へのリンクカードの挿入は以下を参考にしました。

BlueskyのAT Protocolでリンクカード付きのpostを投稿する方法

GAS の設定は以下を参考にしました。

ChatGPTによって指定した分野のPubMed論文を要約して毎日メールかLINEしてもらう方法 | 歯と口腔外科の役立つお話

ChatGPTでarXiv論文紹介を毎朝LINEへ配信 #ChatGPT - Qiita

GAS のスクリプト プロパティにBluesky アカウント・Bluesky パスワード・OpenAI API key を書き込む

スクリプト プロパティを使うとソースコード内に直接値を書かずに秘密情報(例:APIキー、パスワード)を利用できます。参考にしたサイトではベタ貼りされているものがありましたが、怖いので隠します。

GAS ページの左側のタブの一番下の歯車マークをクリックすると、プロジェクトの設定ページが開きます。

プロジェクトの設定ページ

プロジェクトの設定ページの一番下までスクロールするとスクリプト プロパティがあります。

スクリプト プロパティ

スクリプト プロパティを追加」をクリックします。

  • IDENTIFIER にBluesky アカウント
  • OPENAI_API_KEY に先ほど保存したOpenAI のAPI key
  • PASSWORD にBluesky パスワード

を入力し、 「スクリプト プロパティを保存」で保存します。

プロパティ名(IDENTIFIER, OPENAI_API_KEY, PASSWORD)をこのブログと違うものに変えてしまうとコピペコードで実行できなくなるので、注意してください。

これで準備完了です。

 

GAS スクリプトの準備

コードのコピペ&ペースト

GAS ページの左側にあるタブの中にある<>アイコンをクリックするとエディタが開くので、そこに以下のコピペ用コードをまるっと貼り、赤太字の部分を自分好みにカスタマイズして使います。どこで何をしているかはコメントしています。

このコードはPUBMED_QUERY に入れた検索タームで過去二週間分のPubMed 論文を検索し、条件に合う論文のタイトルとごく短い要約を日本語で書きだし、英語タイトルと雑誌名略称とアブストの一部が入ったリンクカードの挿入を行います。それらの操作をすべて自動で行います。

また、重複した投稿がなされないよう、POSTED_PAPER_IDS に過去に取得済みの論文id を自動で保存し、同じ内容が投稿されないようチェックしています。取得id は直近二週間分を残して消える仕組みにし、溜まりすぎないようにしました。

PUBMED_QUERY にいれるMesh term はhttps://www.ncbi.nlm.nih.gov/mesh/ から検索できます。検索タームが長すぎるとエラーが出たり、検索条件から外れた論文もなぜか引っ張ってきてしまうようになるので、シンプルにするのがおすすめです。

ちゃんとBluesky アカウントに投稿されるかどうかはエディタの左上の「▷実行」を押して確認できます。投稿ができるのを確認したら次の自動投稿の設定に移ります。

コピペ用コード

// ChatGPT に渡す命令
const PROMPT_PREFIX = "あなたは神経系と動物行動に詳しい研究者です。与えられた論文の日本語タイトルを出力してください。次に、与えられた論文の要点を50字以内2点のみでまとめ、以下のフォーマットで日本語で出力してください。\
タイトル \
実験対象 \
・要点1 \
・要点2 \
"

// PubMed の検索クエリ
const PUBMED_QUERY = '"Behavior, Animal"[MeSH] NOT "Humans"[Mesh]';

// PubMed の対象の記事タイプ
const PUBMED_PUBTYPES = ["Journal Article", "Books and Documents", "Clinical Trial", "Meta-Analysis", "Randomized Controlled Trial", "Review", "Systematic Review"];
// PubMed の検索対象日数
const PUBMED_TERM = 14;
// PubMed の検索時のヒット論文で要約する論文の本数の上限
const MAX_PAPER_COUNT = 1;

 

// 重複投稿を防ぐ!
// 投稿済み論文IDを取得し、一定期間後に古いデータを削除することで重複投稿を防ぐ
// 投稿済みの論文IDとその投稿日を取得する関数
function getPostedPaperIds() {
    const postedData = PropertiesService.getScriptProperties().getProperty("POSTED_PAPER_IDS");
    let postedIdsWithDates = postedData ? JSON.parse(postedData) : {};
    const twoWeeksAgo = new Date();
    twoWeeksAgo.setDate(twoWeeksAgo.getDate() - 14); // 二週間前の日付を計算

    // 二週間以内のものだけを残します(古いログの削除)
    for (const [id, date] of Object.entries(postedIdsWithDates)) {
        if (new Date(date) < twoWeeksAgo) {
            delete postedIdsWithDates[id]; // 二週間より前のデータは削除
        }
    }

    // 更新したデータをスクリプトのプロパティに保存
    PropertiesService.getScriptProperties().setProperty("POSTED_PAPER_IDS", JSON.stringify(postedIdsWithDates));

    // 更新後のIDのリストを返す
    return Object.keys(postedIdsWithDates);
}

// 論文IDを投稿日とともに保存する関数
function savePostedPaperId(id) {
    const postedData = PropertiesService.getScriptProperties().getProperty("POSTED_PAPER_IDS");
    let postedIdsWithDates = postedData ? JSON.parse(postedData) : {};
    
    // 現在の日時をISO形式で保存
    postedIdsWithDates[id] = new Date().toISOString();
    
    PropertiesService.getScriptProperties().setProperty("POSTED_PAPER_IDS", JSON.stringify(postedIdsWithDates));
}

// Google Apps Script エディタでの論文ID ログ出力
function logPostedPaperIds() {
    const postedIds = getPostedPaperIds();
}

// スクリプトのメイン関数
// ここでAPIキーを取得し、PubMedから論文情報を取得してChatGPTに要約させ、Blueskyに投稿
function main() {
    const OPENAI_API_KEY = PropertiesService.getScriptProperties().getProperty("OPENAI_API_KEY");
    const sessionData = loadData(); // loadData関数から取得したセッションデータを使用
    const BLUESKY_API_TOKEN = sessionData ? sessionData.accessJwt : null;

    if (!OPENAI_API_KEY || !BLUESKY_API_TOKEN) {
        console.error("ERROR: OPENAI_API_KEY or BLUESKY_API_TOKEN is not set. Please set them in the Script Properties or retrieve successfully.");
        return;
    }
    // スクリプトのメイン関数内で過去14日間の論文IDを取得する部分
    // getPaperIDsOn関数を正しく呼び出す
    const today = new Date(); // 今日の日付
    const fourteenDaysAgo = new Date();
    fourteenDaysAgo.setDate(today.getDate() - 14); // 14日前の日付

    // ここでエラーが発生していた場合、startDate と endDate が正しく Date オブジェクトとして扱われているかを確認
    console.log(`StartDate: ${toYYYYMMDD(fourteenDaysAgo)}, EndDate: ${toYYYYMMDD(today)}`);

    // 更新された関数を使用して、過去14日間の論文IDを取得
    const ids = getPaperIDsOn(fourteenDaysAgo, today);
 

    let output = "【PubMed 新着】";
    let paperCount = 0;
    const postedIds = getPostedPaperIds(); // 既に投稿された論文IDのリストを取得

    for (let i = 0; i < ids.length; i++) {
        const id = ids[i];
        
        if (postedIds.includes(id)) {
            console.log(`ID ${id}は既に投稿されています。スキップします。`);
            continue; // このIDの処理をスキップ
        }
      
        Utilities.sleep(1000); // APIリクエスト間の遅延を導入

        try {
            const summary = getPaperSummaryByID(id);
            if (!summary || !summary.title) {
                console.error(`ID ${id}のsummaryが正しく取得できていないか、タイトルがありません。`);
                continue; // タイトルがなければこのIDの処理をスキップ
            }
            
            if (++paperCount > MAX_PAPER_COUNT) break; // 最大処理数に達したらループを終了

            const abstract = getPaperAbstractByID(id);
            const input = `\ntitle: ${summary.title}\nabstract: ${abstract}`;
            const res = callChatGPT(input, OPENAI_API_KEY);

            const paragraphs = res.choices.map(c => c.message.content.trim()).join("\n");
            const paperUrl = `https://pubmed.ncbi.nlm.nih.gov/${id}`;
            // リンクカードに挿入する【雑誌の略称】とアブストラクトを定義
            const descriptionWithJournal = `【${summary.source}】${abstract.substring(0, 127) + '...'}`; // 130文字制限を適用
            postToBluesky(paragraphs, BLUESKY_API_TOKEN, paperUrl, summary.title, descriptionWithJournal);
            
            // IDを投稿済みリストに追加
            savePostedPaperId(id);
        } catch (e) {
            console.error(`An error occurred while processing ID ${id}: ${e.message}`);
        }
    }

    if (paperCount === 0) {
        output += "検索条件に合致した新着論文はありませんでした。";
    }
    output = output.trim();
    console.log(output);
    // IDリストをログに出力する
    logPostedPaperIds();

// 現在の日付で新たにPubMedから論文IDを取得
const newIds = getPaperIDsOn(today, today);

// 取得した各IDに対して処理を実行
newIds.forEach(id => {
    // 論文の要約と抄録を取得
    const summary = getPaperSummaryByID(id);
    const abstract = getPaperAbstractByID(id);
    // コンソールに論文の要約を出力
    console.log(`ID ${id}のsummary:`, summary);
    // 要約と抄録が取得できた場合のみ処理を続行
    if (summary && abstract) {
        // PubMed論文のURLを構築
        const paperUrl = `https://pubmed.ncbi.nlm.nih.gov/${id}`;
        // Blueskyに論文の要約とリンクカードを投稿
        postToBluesky(paragraphs.join("\n"), BLUESKY_API_TOKEN, paperUrl, summary.title, descriptionWithJournal);
    }
});
}

// 論文の要約とリンクカードをBlueskyに投稿する関数
// titleとdescriptionを適切に設定し、Bluesky APIを通じて投稿を行う
function postToBluesky(content, token, paperUrl, title, description) {
    // descriptionを130文字以内に制限
    // Bluesky の投稿上限文字数が300 文字であるため、投稿内容とリンクカードと合わせても300 文字を超えないように
    if (description.length > 130) {
        description = description.substring(0, 127) + '...';
    }

    const url = 'https://bsky.social/xrpc/com.atproto.repo.createRecord';
    const loadedData = loadData();
    const did = loadedData.did;
    const embed = {
        $type: "app.bsky.embed.external",
        external: {
            uri: paperUrl,
            title: title, // 検証後のtitleを使用
            description: description, // 130文字以内に制限されたdescriptionを使用
        }
    };

    const record = {
        "text": content,
        "createdAt": new Date().toISOString(),
        "embed": embed
    };

    const data = {
        'repo': did,
        'collection': 'app.bsky.feed.post',
        'record': record
    };

    const options = {
        'method': 'post',
        'headers': {
            'Authorization': `Bearer ${token}`,
            'Content-Type': 'application/json; charset=UTF-8'
        },
        'payload': JSON.stringify(data),
    };

    try {
        const response = UrlFetchApp.fetch(url, options);
        if (response.getResponseCode() === 200) {
            console.log("Successfully posted to Bluesky.");
        } else {
            console.error("Failed to post to Bluesky: ", response.getContentText());
        }
    } catch (e) {
        console.error("An error occurred while posting to Bluesky: ", e.toString());
    }
}

 

// PubMedから論文のIDを取得する関数や、取得したIDに基づいて論文の要約や詳細情報を取得する関数など、スクリプト全体で共通して使用されるヘルパー関数群

// Bluesky APIにログインセッションを作成するためのloadData関数
// スクリプトのプロパティに保存されたユーザー識別子とパスワードを使用してBlueskyにログインし、セッション情報を取得します。
function loadData() {
    // スクリプトのプロパティからユーザー識別子とパスワードを取得
    const identifier = PropertiesService.getScriptProperties().getProperty("IDENTIFIER");
    const password = PropertiesService.getScriptProperties().getProperty("PASSWORD");
    // Blueskyのセッション作成APIのURL
    const url = 'https://bsky.social/xrpc/com.atproto.server.createSession';
    // APIリクエストに必要なデータ
    const data = {'identifier': identifier, 'password': password};
    const options = {
        'method': 'post',
        'headers': {'Content-Type': 'application/json; charset=UTF-8'},
        'payload': JSON.stringify(data),
        'muteHttpExceptions': true
    };
    // APIリクエストを実行し、結果を返す
    try {
        const response = UrlFetchApp.fetch(url, options);
        const responseCode = response.getResponseCode();
        const responseBody = JSON.parse(response.getContentText());
        if (responseCode >= 200 && responseCode < 300) {
            console.log("APIリクエスト成功:", responseBody);
            return responseBody;
        } else {
            console.error("APIリクエスト失敗:", responseCode, responseBody);
            return null;
        }
    } catch (e) {
        console.error("APIリクエスト中にエラーが発生しました:", e.toString());
        return null;
    }
}

// 日付をYYYY/MM/DD形式に変換する関数
//function toYYYYMMDD(date) {
//    return [date.getFullYear(), date.getMonth() + 1, date.getDate()].join("/");
// }
function toYYYYMMDD(date) {
    console.log(`toYYYYMMDD - Received date: ${date}, Type: ${typeof date}`);
    if (!(date instanceof Date)) {
        console.error("toYYYYMMDD - Error: Not a Date object");
        return null;
    }        
    // 月と日が1桁の場合、先頭に0を追加する
    const month = (date.getMonth() + 1).toString().padStart(2, '0');
    const day = date.getDate().toString().padStart(2, '0');
    return `${date.getFullYear()}/${month}/${day}`;
}

// 指定されたPubMedの論文IDに基づき、論文の要約情報を取得する関数
function getPaperSummaryByID(id) {
    const url = `https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esummary.fcgi?db=pubmed&retmode=json&id=${id}`;
    console.log(url);
    const res = JSON.parse(UrlFetchApp.fetch(url).getContentText());
    return res.result[id];
}

// 指定されたPubMedの論文IDに基づき、論文の抄録を取得する関数
function getPaperAbstractByID(id) {
    const url = `https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi?db=pubmed&retmode=xml&id=${id}`;
    const xml = UrlFetchApp.fetch(url).getContentText();
    const match = xml.match(/<Abstract>(.*?)<\/Abstract>/);
    return match ? match[1] : "";
}

// 指定された論文のタイプが設定した条件に合致するかをチェックする関数
function checkPubtype(pubtypes) {
    const common = pubtypes.filter(x => PUBMED_PUBTYPES.indexOf(x) !== -1);
    return common.length > 0;
}

// 指定された日付に基づき、PubMedから論文のIDを取得する関数
// 過去二週間の論文IDを取得する関数(更新版)
// getPaperIDsOn関数内でのendDateの扱いを確認
function getPaperIDsOn(startDate, endDate) {
    console.log(`getPaperIDsOn - StartDate: ${startDate}, EndDate: ${endDate}`); // デバッグログ
    console.log(`getPaperIDsOn - StartDate type: ${typeof startDate}, EndDate type: ${typeof endDate}`); // データタイプ確認
    const query = encodeURIComponent(PUBMED_QUERY);
    
    // ここでstartDateとendDateをtoYYYYMMDDに渡す
    const startDateFormatted = toYYYYMMDD(startDate);
    const endDateFormatted = toYYYYMMDD(endDate);
    const url = `https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi?db=pubmed&retmode=json&sort=pub_date&term=${query}&mindate=${startDateFormatted}&maxdate=${endDateFormatted}`;
    console.log(url);
    try {
        const response = UrlFetchApp.fetch(url);
        const data = JSON.parse(response.getContentText());
        const ids = data.esearchresult.idlist;
        return ids;
    } catch (e) {
        console.error("Failed to fetch paper IDs: " + e.message);
        return []; // エラーが発生した場合は空のリストを返します
    }
}


// OpenAIのChatGPT APIを呼び出し、指定された入力に基づいて応答を取得する関数
function callChatGPT(input, apiKey) {
    const url = "https://api.openai.com/v1/chat/completions";
    const options = {
        method: "post",
        headers: {
            Authorization: `Bearer ${apiKey}`,
            "Content-Type": "application/json",
        },
        payload: JSON.stringify({
            model: "gpt-3.5-turbo", // ここを利用可能なモデルに変更
            messages: [{ role: "user", content: PROMPT_PREFIX + "\n" + input }],
        }),
    };
    
    try {
        const response = UrlFetchApp.fetch(url, options);
        if (response.getResponseCode() === 200) {
            return JSON.parse(response.getContentText());
        } else {
            console.error(`Failed to call OpenAI API: ${response.getContentText()}`);
        }
    } catch (e) {
        console.error(`An error occurred: ${e.message}`);
    }
}

定期的に投稿するためのトリガーを設定

GAS の左タブから「トリガー」を選択し、クリックします。

トリガー画面

「トリガーを追加」をクリックします。

トリガーの設定画面
  • 実行する関数 → main
  • 実行するデプロイ → Head
  • イベントのソースを選択 → 時間主導型
  • トリガーのタイプを選択 → 時間ベースのタイマー
  • 時間の間隔を選択(時間)→ 何時間ごとに投稿されるかをお好みで変更

で保存します。

トリガーを保存すると、Google アカウント選択画面が立ち上がるので、アカウントを選択します。

Google アカウント選択画面が立ち上がる

次に警告画面が立ち上がります。

警告画面

左下の「Advanced」をクリックし、左下のGo to GAS 名 (unsafe) をさらにクリックします。

次の画面で右下の「Allow」をクリックし、すべての設定は終了です。

あとは設定時間までニコニコ待つだけです。

 

【New!】サポート機能を導入しました!

月3000 円の月謝は(安くない♪)ので、今回投げ銭機能をブログにつけてみました!すでにお気づきかもしれませんがサイドバーにも同じボタンがあります。

気が向いたら応援してみてください!中の人が喜びます。

投げ銭機能の導入は以下のブログを参考にしたおかげでボタンのカスタマイズも含めて簡単にできました。ありがとうございます。

yunico-fluffylife.com

参考にしたサイトのまとめ

zenn.dev

note.com

qiita.com

www.dental-oral-surgery.com

qiita.com