Paperpileに保存した論文をGeminiに自動要約させるコード


  • 文献管理アプリPaperpileはGoogle Driveをストレージとして論文を保存する
  • GeminiアプリではGoogle Workspaceと連携でき、@Googleドライブ foo.pdfというファイルの要約をしてといったプロンプトでGoogle Driveを読むことができる
    • Geminiアプリ上で手動で↑を行うことはできるが、Gemini APIにGoogle Driveを読むオプションは現状ない
  • GASはGoogle DriveとGeminiの両方と連携することができ、書いたコードをAPIとして公開できる

以上から、このフローが実現できる。

  • GASにAPIリクエストを投げる
    • ファイルパス、モデル、プロンプトを指定する
  • → GAS内でGoogle Drive、Geminiにアクセスしてプロンプトを実行する
  • → APIレスポンスを自由に使う

プロパティの設定とかデプロイとかは省略するのでLLMに聞いて~

課題?:

  • ストリーミングに対応してないのでFlashでも実行時間がかなり長い
    • Obsidianでは終わったら通知するようにしてる
  • 反復のチャットに対応していない
    • 一回論文の要約させるくらいの使い道

GAS

※ デプロイは本来は自分だけにしたいがfetchすると認証が挟まってうまく取得できないのでセキュリティを犠牲に全体公開している

// See https://developers.google.com/apps-script/guides/properties
// for instructions on how to set the API key.
const apiKey = PropertiesService.getScriptProperties().getProperty('GEMINI_API_KEY');

function doGet(e) {
  const { path, prompt, model } = e.parameter;

  if (!path || !prompt || !model) {
    return ContentService.createTextOutput(JSON.stringify({ error: 'Missing parameters.' }))
        .setMimeType(ContentService.MimeType.JSON);
  }

  try {
    const fileInfo = uploadToGemini(path);

    const generationConfig = {
      temperature: 1,
      topP: 0.95,
      topK: 64,
      maxOutputTokens: 65536,
      responseMimeType: 'text/plain',
    };

    const data = {
      generationConfig,
      contents: [
        {
          role: 'user',
          parts: [
            {
              fileData: {
                fileUri: fileInfo.uri,
                mimeType: fileInfo.mimeType
              }
            },
          ],
        },
        {
          role: 'user',
          parts: [
            { text: prompt },
          ],
        },
      ],
    };

    const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`;
    const options = {
      method: 'POST',
      contentType: 'application/json',
      payload: JSON.stringify(data)
    };

    const response = UrlFetchApp.fetch(url, options);

    return ContentService.createTextOutput(response.getContentText())
        .setMimeType(ContentService.MimeType.TEXT);
  } catch (error) {
    return ContentService.createTextOutput(JSON.stringify({ error: error.toString() }))
        .setMimeType(ContentService.MimeType.JSON);
  }
}

/**
 * Uploads a file to Gemini and waits for it to become active.
 *
 * @param {string} fileName - The name of the file in Google Drive.
 * @return {Object} An object containing the display name, URI, and MIME type of the uploaded file.
 * @throws {Error} If the file is not found in Drive or fails to process in Gemini.
 */
function uploadToGemini(fileName) {
  const file = getFileFromDrive(fileName);

  if (!file) {
    throw new Error(`Error: File "${fileName}" not found in Drive.`);
  }

  const numBytes = file.getSize();
  const mimeType = file.getMimeType();
  const blob = file.getBlob();

  const url = `https://generativelanguage.googleapis.com/upload/v1beta/files?key=${apiKey}`;
  const options = {
    method: 'POST',
    headers: {
      'X-Goog-Upload-Command': 'start, upload, finalize',
      'X-Goog-Upload-Header-Content-Length': numBytes,
      'X-Goog-Upload-Header-Content-Type': mimeType,
    },
    payload: blob,
  }
  const response = UrlFetchApp.fetch(url, options);
  let geminiFile = JSON.parse(response.getContentText()).file;

  while (geminiFile.state === 'PROCESSING') {
    Utilities.sleep(10000); // Wait for 10 seconds
    geminiFile = getFileFromGemini(geminiFile.uri);
  }

  if (geminiFile.state !== 'ACTIVE') {
    throw new Error(`Error: File ${fileName} failed to process in Gemini.`);
  }

  return geminiFile;
}

/**
 * Retrieves a file from Google Drive by its path.
 *
 * @param {string} filePath - The path of the file to retrieve.
 * @return {Object} The file object if found, null otherwise.
 */
function getFileFromDrive(filePath) {
  const pathParts = filePath.split('/');
  const fileName = pathParts.pop();
  let folder;

  if (pathParts.length > 0) {
    let currentFolder = DriveApp.getRootFolder();
    for (const folderName of pathParts) {
      const folders = currentFolder.getFoldersByName(folderName);
      if (folders.hasNext()) {
        currentFolder = folders.next();
      } else {
        // Folder not found
        return null;
      }
    }
    folder = currentFolder;
  } else {
    folder = DriveApp.getRootFolder();
  }

  const files = folder.getFilesByName(fileName);
  if (files.hasNext()) {
    return files.next();
  }
  return null;
}

/**
 * Retrieves the status of a file from Gemini.
 *
 * @param {string} fileUri - The URI of the file in Gemini.
 * @return {Object} The file object from Gemini.
 */
function getFileFromGemini(fileUri) {
  const response = UrlFetchApp.fetch(`${fileUri}?key=${apiKey}`);
  return JSON.parse(response.getContentText());
}

実行コード

const filePropertyValue = "ファイルパス";
if (!filePropertyValue) {
  console.log("fileプロパティが空です");
  return;
}

const baseUrl = "https://デプロイしたGASのURL";
const filePath = `Paperpile/${filePropertyValue}`;
const model = "gemini-2.5-flash-preview-04-17";
const prompt = "この論文の概要を日本語で簡潔にまとめてください。";

try {
  const url = new URL(baseUrl);
  url.searchParams.append("path", filePath);
  url.searchParams.append("prompt", prompt);
  url.searchParams.append("model", model);

  console.log("Fetching...");

  const response = await fetch(url.toString())
    .then((res) => res.json())
    .then((data) => data.candidates[0].content.parts[0].text);

  console.log(response);
  console.log("Summary completed!");
} catch (error) {
  console.error("API呼び出しエラー:", error);
  console.log("API呼び出しでエラーが発生しました");
}

Obsidian Citation プラグインと使う

PaperpileからBibtexエクスポートを行っている前提。fileを含む必要がある。

---
title: "{{title}}"
aliases:
  - "@{{citekey}}"
authors: [ {{authorString}} ]
journal: "{{containerTitle}}"
year: {{year}}
url: "{{URL}}"
{{#if eprint}}arxiv url: "https://arxiv.org/abs/{{eprint}}"{{/if}}
doi: "{{DOI}}"
file: "{{entry.files}}"
created_at: <% tp.date.now("YYYY-MM-DDTHH:mm:ss") %>
updated_at: <% tp.date.now("YYYY-MM-DDTHH:mm:ss") %>
related:
---

## Abstract

{{abstract}}

<%*
const wrap = (text) => `> [!SUMMARY]- Summary from Gemini
> ${text.split("
").join("
> ")}`;

const _filePath = tp.frontmatter["file"];
if (!_filePath) {
	tR += wrap("fileプロパティが空です");
	return;
}

const baseUrl = "https://デプロイしたURL";
const filePath = `Paperpile/${tp.frontmatter["file"]}`;
const model = "gemini-2.5-flash-preview-04-17";
const prompt = `要約して~~~~~`;

try {
	const url = new URL(baseUrl);
	url.searchParams.append('path', filePath);
	url.searchParams.append('prompt', prompt);
	url.searchParams.append('model', model);

	tR += wrap("Fetching...");

	const response = await tp.web.request(url.toString(), "candidates.0.content.parts.0.text");
	tR += wrap(response);

        new Notice("Summary completed!");
} catch (error) {
	console.error("API呼び出しエラー:", error);
	tR += wrap("API呼び出しでエラーが発生しました");
}
%>

## メモ

...