Gemini×GAS×LineBot×notionAPIで簡単メモbot

2024/01/04に公開

はじめに

lineでxx行きたいみたいな話をしてもなかなか行かないことが多いので情報を簡単にまとめて見れるようにしました。
あとgeminiがしばらく無料らしいので使ってみたかったのもあります。

完成イメージ

↓みたいな感じでtiktokやinstagramの飲食店、観光名所、買いたいものなどを紹介してる投稿のurlを送るとNotionでいい感じにまとめてくれる。

↓↓↓↓↓↓↓

したこと

Gemini×GAS×LineBot×notionAPIを適当につなげて、lineでbotにurl送るとその内容を適当にまとめてnotionのDBに送るようなものを作成しました。

したことは単純でいろんな記事組み合わせたら出来ました。

LineBot->GAS

こちらは色んな記事があるので省略させてください。
参考:https://tech-lab.sios.jp/archives/33512

基本的な雛形は以下のようになります。

const LINE_TOKEN = PropertiesService.getScriptProperties().getProperty("LINE_ACCESS_TOKEN");
const LINE_URL = 'https://api.line.me/v2/bot/message/reply';

function doPost(e){
  const json = JSON.parse(e.postData.contents);
  
  const reply_token = json.events[0].replyToken;
  const messageId = json.events[0].message.id;
  const userId = json.events[0].source.userId;
  const messageType = json.events[0].message.type;
  const messageText = json.events[0].message.text;
  const message = extractURL(messageText);
  //ここのmessageを使って色々する
  
  reply("hogehoge",reply_token);
  //返事はここで書く
  
  return;
}

function reply(message,reply_token){
  const option = {
    'headers': {
      'Content-Type': 'application/json; charset=UTF-8',
      'Authorization': 'Bearer ' + LINE_TOKEN,
    },
    'method': 'post',
    'payload': JSON.stringify({
      'replyToken': reply_token,
      'messages': [{
        'type': 'text',
        'text': message,
      }],
    }),
  }

  UrlFetchApp.fetch(LINE_URL,option);
  return;
}

GAS->GEMINI

参考にしたもの:https://qiita.com/gas-suke/items/323ce91ded308470a48f
今回はfunnction callingの機能を使ったので微妙にすることが違います。が基本的にはAPIKeyを発行し、APIに投げるだけです。

こちらは文字列からGEMINIに問い合わせる関数です。

const GEMINI_TOKEN = PropertiesService.getScriptProperties().getProperty("GEMINI_TOKEN"); 
const GEMINI_URL = `https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent`;

function getGemini(input_text) {
  const url = GEMINI_URL+`?key=${GEMINI_TOKEN}`
        , payload = {
            'contents': [
              {
                'parts': [{
                  'text': input_text
                }]
              }
            ],
            'tools': [function_tool]
          }
        , options = {
            'method': 'post',
            'contentType': 'application/json',
            'payload': JSON.stringify(payload)
          };

  const res = UrlFetchApp.fetch(url, options)
        , resJson = JSON.parse(res.getContentText());

  if (resJson && resJson.candidates && resJson.candidates.length > 0) {
    return resJson.candidates[0].content.parts[0].functionCall.args;
  } else {
    return '回答を取得できませんでした。';
  }
}

参考にしたものと違うのは'tools': [function_tool]の部分でしょう。こちらはgeminiから帰ってくるjsonの形式を指定したものになります。書き方は詳しくはこちらに書いています。

今回は飲食店、観光名所、買いたいものなのでそれらを踏まえて作成しました。(NotionのDBの構造から考えたほうがよいかも)

const function_tool = {
  "functionDeclarations": [{
    "name": "flier",
    "description": "Generates a JSON structure based on provided input, categorizing it into name, location, tags, table, todo, and notes.",
    "parameters": {
      "type": "OBJECT",
      "properties": {
        "name": {
          "type": "STRING",
          "description": "Title of the input text. This could be the name of a shop, tourist spot, item to purchase, or a task. If the input contains multiple elements, provide a summary. Required field."
        },
        "location": {
          "type": "STRING",
          "description": "Location of the shop or tourist spot, if explicitly mentioned. Optional field."
        },
        "tags": {
          "type": "ARRAY",
          "items": {
            "type": "STRING"
          },
          "description": "A list of tags associated with the input, ranging from broad concepts like geographic names, café, tourist spots, books, cleaning, etc., to specific names. Include as many as possible."
        },
        "table": {
          "type": "ARRAY",
          "items": {
            "type": "STRING",
            "enum": ["SpotList", "Todo", "PurchaseList"]
          },
          "description": "List of categories that the input belongs to. Choose from 'SpotList' (for shops or tourist spots), 'Todo' (for tasks), and 'PurchaseList' (for items to purchase). At least one selection is required."
        },
        "todo": {
          "type": "STRING",
          "description": "A summary of the task, if the input is a 'Todo'. Optional field."
        },
        "note": {
          "type": "STRING",
          "description": "Any additional notes or supplementary information. Optional field."
        }
      },
      "required": ["name"]
    }
  }]
};

このjsonは手動で書くと結構めんどくさいのでchatGPTなどに書いてもらうのがおすすめです。
自分は↓みたいな感じで質問しました。

 "tools": [{
    "functionDeclarations": [{
      "name": "find_movies",
      "description": "find movie titles currently playing in theaters based on any description, genre, title words, etc.",
      "parameters": {
        "type": "OBJECT",
        "properties": {
          "location": {
            "type": "STRING",
            "description": "The city and state, e.g. San Francisco, CA or a zip code e.g. 95616"
          },
          "description": {
            "type": "STRING",
            "description": "Any kind of description including category or genre, title words, attributes, etc."
          }
        },
        "required": ["description"]
      }
    },

上記のような OpenAPI 互換スキーマ形式のオブジェクトを以下のもので作成してください。

関数名:flier
返してほしいjson形態
Name: 文字列で、入力された文章のタイトル。お店や観光地ならその名称、購入するものでもその名称。Todoならすること。それぞれ単体ではなく複数の場合は入力の概要を書きます。必須です。
Location: 文字列でお店や観光地の場合でかつ場所が明示されている場合に書いてください。
Tags: 文字列の配列で入力から連想されるあらゆるタグを思いつく限り書いてください。これは地名やカフェ、観光地、本、掃除なのどの大きな概念から具体的な名称まで全て含みます。可能な限り書いてください。
Table: 文字列のリストで以下の中から該当するものを全て選んでください(SpotList(観光地やお店の場合)、Todo(なにかすることの場合),PurchaseList(物品の場合))。必ず1個以上選択してください。
Todo: Todoの場合のみその具体的な内容を要約して書いてください。
Note: そのた補足するものがあれば書いてください。

GAS->Notion

こちらもいろんなところ記事で紹介されてるのでそれを見て作成しました。
自分が使用してるのは は↓です。input_jsonとdatabase_idでnotionのDBに書き込みます。
ここは geminiの返答の形式やNotionのDBの構造で結構変わってくるところかなと思います。

const NOTION_TOKEN = PropertiesService.getScriptProperties().getProperty("NOTION_TOKEN");
const NOTION_URL = 'https://api.notion.com/v1/pages';

function notion(input_json,database_id) {

  var headers = {
    'Content-Type' : 'application/json; charset=UTF-8',
    'Authorization': 'Bearer ' + NOTION_TOKEN,
    'Notion-Version': '2022-02-22',
  };
var post_data = {
  'parent': {'database_id': database_id},
  'properties': {
    'Name': { 
      'title': [
        {
          'text': {
            'content': input_json.name
          }
        }
      ]
    },
    'URL': { // URLプロパティ
      'url': input_json.url
    },
    'Location': { // テキストプロパティ
      'rich_text': [
        {
          'text': {
            'content': input_json.location
          }
        }
      ]
    },
    'Tags': { // セレクトプロパティまたはマルチセレクトプロパティ
      'multi_select': input_json.tags
    },
    'Table': { // セレクトプロパティまたはマルチセレクトプロパティ
      'multi_select': input_json.table
    },
    'Todo': { // セレクトプロパティまたはマルチセレクトプロパティ
      'rich_text': [
        {
          'text': {
            'content': input_json.todo
          }
        }
      ]
    },
    'Note': { // セレクトプロパティまたはマルチセレクトプロパティ
      'rich_text': [
        {
          'text': {
            'content': input_json.note
          }
        }
      ]
    }

  }
};


  var options = {
    "method" : "post",
    "headers" : headers,
    "payload" : JSON.stringify(post_data)
  };

  return UrlFetchApp.fetch(NOTION_URL, options);  
}

//gemminiの返答からnotionで使えるnotionの形式に変換する箇所
function gemminiToNotionData(gemini_json,url,tag){
  var input_json = {
  "name": "",
  "url":url,
  "location": "",
  "tags": [],
  "table": [],
  "todo": "",
  "note": ""
};
  

  for (let key of ["name","location","todo","note"]) {
    if (key in gemini_json) {
      input_json[key] = gemini_json[key];
    }
  }

  for (var key of ["tags","table"]) {
    if (key in gemini_json) {
      for(var value of gemini_json[key]){
        input_json[key].push({"name":value});
      }
    }
  }

  if (tag && typeof tag === 'string') {
    input_json.tags.push({"name":tag});
  }
  Logger.log(input_json)
return input_json

}

その他GASでしたこと

urlからスクレイピング

cheerioを使用してスクレイピングしました。
本当はtwitterとかyoutubeも対応したかったのですが少しめんどくさそうだったので簡単にできそうなinstagramとtiktokだけ対応しました.

function getBody(url){
  var response = UrlFetchApp.fetch(url); // URLからHTMLを取得
  var html = response.getContentText(); // HTMLテキストを取得

  //デバッグ用で要素を全部出すコード
  /*
  var $ = Cheerio.load(html);
  $('*').each(function(i, elem) {
    // この部分で各要素を扱う
    Logger.log($(this).get(0).tagName + ': ' + $(this).text());
  });

  Logger.log("test");
  
  return ;
  */
  

  // cheerioを使用してHTMLをパース
  var $ = Cheerio.load(html);
  if(url.includes("tiktok")){
    var body = $('body').text()+'上記はあるの投稿のbodyになります。この内容を要約しなさい。';
    return body;
  } 
  if(url.includes("instagram")){
    var body = $('title').text()+'上記はあるの投稿のbodyになります。この内容を要約しなさい。';
    return body;
  } 

  return null;
}

メッセージからurlを取得

こちらは適当にhttpsから始まるものを取る正規表現を作成しました。

function extractURL(text) {
  const regex = /(https?:\/\/[^\s]+)/;
  const match = regex.exec(text);
  return {
    url: match ? match[1] : null,
    prefix: match ? text.substring(0, match.index) : null,
  };
}

debug

実際にデプロイ後エラーがみえずに困ったのでgcpと紐づけてログを見ました。
参考:https://zenn.dev/nenenemo/articles/db5f4d3930276c

終わりに

こういったchatGPTとかの機能は個人的にチャットよりもfunction callingの情報抽出のほうが価値があるように思いました。
このアプリもnotionDBに保存ではなく、どこかのデータベースに入れてgeminiとタグを使ってメモの曖昧検索など色々拡張できる余地があると思います。期間限定とはいえこれが無料なのはありがたいですね。

Discussion