GASとGoogleDriveのOCR機能で文字起こしボットを作ってみた時のTips


今年もLINEWORKSアドベントカレンダー作りまして1番ノリです!
去年は入門編でLINE WORKSのフリープランでBot開発のための設定という記事にしましたが、今年は自分が勉強してみたかったGASを使って何か作ってみようと思い挑戦してみました。

GASとは?

GAS(がす)はGoogle Apps Scriptの略で、Googleが開発・提供している開発プラットフォームです。JavaScriptというWebブラウザ上で動作するプログラミング言語がベースになっています。
Gmail、スプレッドシート、GoogleスケジュールなどなどのGoogleサービスを自動操作できるライブラリが揃っており、Googleアカウントさえあれば無料・有償どちらでも使うことができます。
詳細はこちらのGoogle Apps Scriptガイド参照

作ったもの

LINEWORKSから画像を投稿すると文字起こししたメッセージを返してくれるボットを作ってみました。
領収書もちゃんと文字起こしできるし

文字レイアウトが様々な所に配置されてる料理本でもこのように数秒で文字起こししてくれます。

システム構成とフロー

①LINEWORKSアプリで投稿された写真はクラウド上のLINEWORKSのストレージに保存される
②LINEWORKS API経由でクラウドLINEWORKSから画像ファイルをダウンロードしてGoogle Driveに保存
③Google DriveのOCR機能で画像ファイルからGoogleドキュメントに文字起こししてテキストを取り出しGoogleドキュメントファイルを削除する
④取り出したテキストをLINEWORKSのボットから元のユーザーとのトークルームでメッセージとして送信

必要なもの

  • インターネットに接続できるPC(Windows/Mac/ChromeOSなどブラウザが入っているPCならなんでもOK)
  • LINEWORKSアプリをインストールしたカメラ付きスマホ
  • Googleアカウント(無料)
  • LINEWORKS(無料のフリープランでOK)
    • ボットを使う一般ユーザ(権限はなんでもOK)
    • ボットを動かすための各種キー(Developer Consoleから発行できます)
      • API ID
      • Bot No
      • Server API Consumer Key
      • Server List(ID登録タイプ)
      • Server List(ID登録タイプ)の認証キー

なんと、全部無料で作れちゃう。

かかった日数

ググりまくって試行錯誤しながら1.5日。(手順知ってたら多分1,2時間とかでできそう‥)

コード

とても汚いコードだと思いますが参考になればと思いリンク張っておきます。こちらにご自身のGoogleアカウントでアクセスしてください。

サンプルコードここにも折りたたんで貼り付けておきます
.gs
const propertiy = PropertiesService.getScriptProperties();
const serverId = propertiy.getProperty('serverId');
const apiId = propertiy.getProperty('apiId');
const botNo = propertiy.getProperty('botNo');
const consumerKey = propertiy.getProperty('consumerKey');
const privateKey = "-----BEGIN PRIVATE KEY-----\nここに認証キーが入ります\n-----END PRIVATE KEY-----";
//const privateKey = propertiy.getProperty('privateKey');

// LINEWORKSに画像が投降されたら実行
function doPost(e){
  const lineworksJson = JSON.parse(e.postData.contents);
  const accountId = lineworksJson.source.accountId;
  console.info("accountId: " + accountId);
  const resourceId = lineworksJson.content.resourceId;
  console.info("resourceId: " + resourceId);
  const createdTime = lineworksJson.content.createdTime;
  const jwtToken = getJwtToken(privateKey, serverId);
  const token = getAccessToken(apiId, jwtToken);
  const replyText = getTextFromImage(getLineworksContent(token, resourceId));      // LINEから送られた画像を取得して文字起こしした文字を取得する
  LINEWORKS.sendMsg(setOptions(), accountId, replyText); // LINEWORKSにテキストを返信する
}

// 画像を取得する関数
function getLineworksContent(token, resourceId) {
  const headers = {
    'consumerKey': consumerKey,
    'Authorization': 'Bearer ' + token,
    'x-works-apiid': apiId,
    'x-works-resource-id': resourceId,
  };
  const options = {'method': 'GET','headers': headers};
  const url = 'http://storage.worksmobile.com/openapi/message/download.api';
  const blob = UrlFetchApp.fetch(url, options).getBlob();

  return blob;
}

// アクセストークンを生成
function getAccessToken(apiId, jwtToken) {
  const uri = 'https://auth.worksmobile.com/b/' + apiId + '/server/token';
  const payload = {
    "grant_type" : encodeURIComponent("urn:ietf:params:oauth:grant-type:jwt-bearer"),
    "assertion" : jwtToken
  };
  const options = {
    'method': 'post',
    'headers': {'Content-Type' : 'application/x-www-form-urlencoded'},
    "payload": payload
  };
  const body = UrlFetchApp.fetch(uri, options);
  console.info("access_token: " + JSON.parse(body).access_token);
  return JSON.parse(body).access_token;
}

// アクセストークンを生成するためのJWTトークンを生成
function getJwtToken(privateKey, serverId) {
  const header = Utilities.base64Encode(JSON.stringify({"alg":"RS256","typ":"JWT"}), Utilities.Charset.UTF_8);
  const claimSet = JSON.stringify({
     "iss": serverId,
     "iat": Math.floor(Date.now() / 1000),
     "exp": Math.floor(Date.now() / 1000 + 2000)
  });
  const encodeText = header + "." + Utilities.base64Encode(claimSet, Utilities.Charset.UTF_8).slice(0, -2);
  const signature = Utilities.computeRsaSha256Signature(encodeText, privateKey);
  const jwtToken = encodeText + "." + Utilities.base64Encode(signature).slice(0, -2);
  console.info("jwtToken: " + jwtToken);
  return jwtToken;
}

// 画像から文字を取得する関数
function getTextFromImage(blob) {
  const resource = {title: blob.getName(), mimeType: blob.getContentType()};
  const options = {ocr: true};

  // Google Drive(Document)にファイルを作成し、画像を挿入してテキストを取得する
  const driveFile = Drive.Files.insert(resource, blob, options);
  const document = DocumentApp.openById(driveFile.id);
  const replyText = document.getBody().getText().replace("\n", "");

  Drive.Files.remove(driveFile.id); // Google Driveに作成したファイルを削除する
  console.info("replyText :" + replyText);
  return replyText;
}

// LINEWORKSライブラリを使うための引数をセット
function setOptions(){
  return {
    "apiId" : apiId,
    "consumerKey" : consumerKey,
    "serverId" : serverId,
    "privateKey" : privateKey,
    "botNo" : botNo
  };
}

Tips

このボットを作るにあたって得た知見やポイントです

Google DriveのOCRが無料で使える

このボットを作るにあたって、最初はクレジットカード登録が必要で課金対象であるGCPのGoogle Vision APIを調べてたのですが。なんとGoogle Driveに無料で使えるOCR機能があることに気が付いてこれでええやん!ってなりました。
Google Drive上に画像ファイルをアップロードし、それをGoogleドキュメントで開くとなんと画像から文字起こしされて出力されるので、それをGASで取り出してLINEWORKSから出力させればよさそうです。Googleドキュメントが増えていってしまうのでテキストを取り出したら削除する処理を入れます。

あとで気が付いたんですがこちらの方が同じようなものを作られてるんじゃないかと思います。Developerコミュニティページでも検索してみると同じような悩みを持った方が質問してるかもしれないので探してみるのよいかと思います。

LINEWORKSに投稿された画像ファイルを取り出す

ドキュメントのこちらのAPIを使います。

LINEWORKSストレージから画像を取得する関数
function getLineworksContent(token, resourceId) {
  const headers = {
    'consumerKey': consumerKey,
    'Authorization': 'Bearer ' + token,
    'x-works-apiid': apiId,
    'x-works-resource-id': resourceId,
  };
  const options = {'method': 'GET','headers': headers};
  const url = 'http://storage.worksmobile.com/openapi/message/download.api';
  const blob = UrlFetchApp.fetch(url, options).getBlob();

  return blob;
}

ここの引数であるTokenってのがこのページにあるServer Tokenを指してるってのを理解するのにすっごい時間かかりました。。このServer Tokenを発行するためにJWT生成→JWT 電子署名(signature)→LINE WORKS認証サーバーへのTokenリクエストをするという結構面倒な処理が必要でこれまた苦労しました。。
セオシスさんの技術ブログに助けられました。ありがたや。
結果、このブログのコードのままで使えることがわかったのでコピペで使わせて頂いてます。

環境変数としてプロパティに登録する

GASのコード内に直接キーやトークン値を入れてしまうと、うっかり共有した際にキーが見えてしまったりする可能性があるので、コード上に直接入れない方がよさそうです。
ファイル>プロジェクトのプロパティをクリックし

こんな感じで登録すると環境変数のように使うことができます。

LINEWORKSライブラリでメッセージ投稿

今回くまなく読ませて頂いたのが@kunihirosさんのqiita記事 GoogleAppsScript で LINEWORKS のチャット BOT を作る@kunihirosさんがLINEWORKSライブラリを作って頂いたため使わせて頂きました。3つの引数入れるとメッセージ投稿できるようになってるようですね。ありがたやー。

LINEWORKS.sendMsg(setOptions(), requestObj.source.accountId, sendMsg);

function setOptions(){
  return {
    "apiId" : "API ID",
    "consumerKey" : "Server API Consumer Key",
    "serverId" : "Server List(ID登録タイプ) の ID",
    "privateKey" : "Server List(ID登録タイプ) の認証キー",
    "botNo" : "botNo"
  };
}

GASのログ出力先

GASのログのありかがすごいわかりにくくて苦労しました。ファイル>ログをクリック後で

表示されるポップアップでApps Scriptダッシュボードのリンクをクリックすると

こんな画面で一見ログが見えるように思うのですが、このままだと自分が実行ユーザである場合のログにフィルタされている状態になるので、ここの×をクリックしてフィルタを解除します。

こうするとすべてのログが見えます。

ただ‥ステータスは見えますが、エラー時のログまではここですべて見えるわけではないようで、こちらのブログを参考にしてGCP画面上でstackdriverでログ見ることができました。

いまだによくわかってないこと

どなたかもしご存じでしたら教えて頂けるとありがたいです。

GCPのstackdriverログは有償か?

GCPはクレカ登録必要なので完全無料というわけにはいかないかも?

Server List(ID登録タイプ)の認証キーはプロパティ登録すると使えない?

PrivateKeyはこんな長い英数字の値になるのですが、なぜかコード上で動くのにプロパティ登録すると動かなくなったのでこれだけはコード上に直接入れたままになってます。

この方と同じ悩みを持ってるかも?

ということで、ひとまず試してみた時のTipsをまとめてみました。
GASの便利さや使い方がなんとなくイメージできたので改めて勉強して色々作ってみようと思います。
また、この文字起こしボットのハンズオン手順、アドベントカレンダーの空きの日向けに作ろうと思います。LWTT勉強会でハンズオン勉強会やってみたいですね♪