🦌

シッカソンで作ったもの

2023/12/04に公開

長野県長和町で行われたシッカソンに参加してきました。LINEbotを使って獣害被害対策を考えようという企画です。そこでは罠の見回りをフォローするラインボットを作成しました。
これを実装した技術的なことをまとめておきます。

何を作ったか?

罠を仕掛けた位置を記録したり、かかった害獣の役所への申請などを手伝ってくれるLINE Botです。位置情報や標識の撮影、取れた獲物の写真などを撮影し、役所への申請に必要な情報や書類の作成をお助けするBotです。

使ったテクノロジー

以下のものを使っています。

  • LINE Message API → お題が”LINEbotを使って”なので当然です。
  • GAS → LINE Message APIで呼ばれるWebHookのホスティングと各種処理を行います。
  • Google Sheet → スプレッドシートを各種DBとして使用しています。
  • Google Cloud Vision API → 写真に写った標識の文字のOCRに使っています。
  • Google Drive → Lineで送られた画像や生成した書類のPDFなどを保存します。
  • Google AppSheets → 情報を閲覧するWebアプリに使用します。
  • EPSON Connect → Google Driveに保存した書類などを印刷するのに使用します。

全体像はこんな感じです。

なぜこれらを使ったのか

まずは簡単に作りたかったこと。後はほかの人がいろいろ改良する際にとっつきやすいだろうな、ということを考えて使いました。Excelのマクロ使える人ならGASもアレルギーないだろうと。
今回のハッカソンの後に、終わった後に実装に移るとあり、その時に引き渡ししやすいだろうなと思ったのも一つの理由です。

どのように作ったか

全体を説明するようなものでもないので、各機能との連携などについて説明します。

デバッグをどうするか?

GASでは、個々の関数はコンソールからデバッグできるのですが、doPost()やdoGet()などのHTTP/HTTPSでアクセスされたときに実行される関数は普通にコンソールへのログも出力できない(出来なくはないけど、GCPのプロジェクトと連携させるなどのめんどくさい設定が必要)ので非常に大変です。なので、このような形で進めました。

  • debugというシートを作っておく
  • 以下のログ出力を行う関数を用意する
// Logをシートに記載する
function debug_log( value)
{
  let debug_sheet = SpreadsheetApp.openById(SpreadSheet_ID).getSheetByName('debug');
  debug_sheet.activate();
  debug_sheet.appendRow([new Date(), value]);
}

必要に応じてdebug_log()を呼び出すと「debug」シートにログが追記されます。doPost()が読みだされた直後に引数をログに出力しておくと、テストケースを作るときに便利です。

トークンやAPIキーなどの扱い

AppScriptの設定でスクリプトプロパティに設定し、それを読みだすようにしています。
コードから取得する場合は以下のように行います。

var TOKEN = PropertiesService.getScriptPropertied().getProperty("TOKEN")

LINE Botの構成

基本的にリッチメニューとクイックリプライによる定型のフローとしました。
フローを作る際に今どの常態なのかを保持する必要があります。今回、userというシートを作り、ここに状態を保持しました。LINE Message APIでメッセージイベントが来たときに、useridが格納されています。今回はそれをキーとして状態を管理するDBとしました。
今後、ここに名前や猟銃の従事者章番号とか登録できるようにしていきます。

// 状態をシートに記載する
function set_state( id, state )
{
  let user_sheet = SpreadsheetApp.openById(SpreadSheet_ID).getSheetByName('user');
  user_sheet.activate();
  
  var textFinder = user_sheet.createTextFinder(id);
  var cells = textFinder.findAll();
  
  if ( cells.length == 0 ) {
    // 新規のIDなら自動で追加する
    user_sheet.appendRow([new Date(), id, state]);
  } else {
    // とりあえず最初に見つかったIDを使う。重複はしないはず
    var row = cells[0].getRow();
    var column = cells[0].getColumn();
    user_sheet.getRange(row,column + 1).setValue(state);
  }
}

// 状態をシートから取得する
function set_state( id )
{
  let user_sheet = SpreadsheetApp.openById(SpreadSheet_ID).getSheetByName('user');
  user_sheet.activate();
  
  var textFinder = user_sheet.createTextFinder(id);
  var cells = textFinder.findAll();
  
  if ( cells.length == 0 ) {
    return null;
  } 
  // とりあえず最初に見つかったIDを使う。重複はしないはず
  var row = cells[0].getRow();
  var column = cells[0].getColumn();
  return user_sheet.getRange(row,column + 1).getValue();
}

LLINEから送られた画像を取得する

LINE Message APIからは、画像に関する情報のみが送られ、画像データ自体は別に取得する必要があります。実際にはMessage APIから以下のようなデータがPOSTされます。

{
	"destination": "xxxxxx",
	"events": [
		{
			"type": "message",
			"message": {
				"type": "image",
				"id": "メッセージのID",
				"quoteToken": "トークンのID",
				"contentProvider": {
					"type": "line"
				}
			},
			"webhookEventId": "WebHookのイベントID",
			"deliveryContext": {
				"isRedelivery": false
			},
			"timestamp": 1701499870377,
			"source": {
				"type": "user",
				"userId": "ユーザーID"
			},
			"replyToken": "リプライトークン",
			"mode": "active"
		}
	]
}

LINEから画像を取得するには以下のAPIを用いて取得します。

https://api-data.line.me/v2/bot/message/メッセージID/content

このAPIを使って画像データを取得します。

// 送信された画像の取得
function getImage(id, filename) {
  //画像取得用エンドポイント
  var url = 'https://api-data.line.me/v2/bot/message/' + id + '/content';
  var response = UrlFetchApp.fetch(url,{
    'headers': {
      'Content-Type': 'application/json; charset=UTF-8',
      'Authorization' :  'Bearer ' + TOKEN,
    },
    'method': 'get'
  });

  var code = response.getResponseCode();
  
  var blob = response.getBlob();
  var img = blob.getAs('image/png');
//  var img = data.getBlob().getAs('image/png').setName(filename);
  return img;
}

画像データをGoogle Driveに保存する

以下のように行います。フォルダIDの調べ方は検索すればすぐ出てくると思います。
以下ではファイルを保存し、その保存先URLを戻します。

function saveImage(blob, folder_id, fileName) {
  try{

    var file_id = DriveApp.getFolderById(folder_id).createFile(blob.setName(fileName));
    return file_id.getUrl();

  }catch(e){
    return false;
  }
}

画像をCloud Vision APIで解析する

画像データをBase64でエンコードする

解析する画像を読み出し、Base64でエンコードする必要があります。
Base64でエンコードするには以下のように行います。

  const base64EncodedFile = Utilities.base64Encode(img.getBytes());

APIを呼び出す

画像をGoogle Cloud Vision APIでOCRしたりするとき、以下のように処理します。
ここで引数に渡すimageはBase64でエンコードされたデータを渡します。

function analyzeImage(image) {

   const apiKey = GOOGLE_API_KEY;
   const url = 'https://vision.googleapis.com/v1/images:annotate?key=' + apiKey;

   // 画像からテキストの検出
   const body = {
       "requests": [
           {
               "image": {
                   "content": image
               },
               "features": [
                   {
                       "type": "DOCUMENT_TEXT_DETECTION",
                   }
               ],
               "imageContext": {
                   "languageHints": ["jp-t-i0-handwrit"]
               }
           }
       ]
   };


   const head = {
       "method": "post",
       "contentType": "application/json",
       "payload": JSON.stringify(body),
       "muteHttpExceptions": true
   };

   const response = UrlFetchApp.fetch(url, head);
   console.log(response.getContentText());
  const result = JSON.parse(response.getContentText());

  return result;
}

自動生成した書類を印刷する

今回のハッカソンでどうしても使いたかった機能です。書類とか自動で作れるほうが良いなと思い。
実現するために以下の手順で行っています。

書類をPDFとして保存する

まず、印刷するもととなる書類のPDFを作成する必要があります。以下の手順で行っています。

書類のもとになるシートを作成する

印刷が必要な書類のフォーマットを作っておきます。この時印刷プレビューできちんと用紙に入って、フォーマットが乱れないことを確認しておくのが大切です。

データをもとに、書類のシートにデータを記入する。

これはフォーマットのシートの必要なセルに必要なデータをセットしていきます。

書類のシートをPDFとしてGoogle Driveに保存する

印刷する書類をPDFとしてGoogle Driveに保存します。
手順としてはPDF生成のAPIを呼び出して、そこから取得できたデータをGoogle Driveに保存します。APIを呼び出す際に印刷設定をパラメータとして与えます。この時、先に印刷状態を確認した時と同じ設定でないと意図した状態で生成されないと思われます。

// PDF生成用APIを呼び出すURLを生成する
function createUrlForPdf(spreadsheet, sheet_name) {
  const params = {
    'exportFormat': 'pdf',
    'format': 'pdf',
    'gid': spreadsheet.getSheetByName(sheet_name).getSheetId(), // シート名を指定して出力対象シートのIDを指定
    'size': 'A4', // 用紙サイズ:A4
    'portrait': true, // 用紙向き:縦
    'fitw': true, // 幅を用紙に合わせる
    'horizontal_alignment': 'CENTER', // 水平方向:中央
    'gridlines': false, // グリッドライン:非表示
  }
  const query = Object.keys(params).map(function(key) {
    return encodeURIComponent(key) + '=' + encodeURIComponent(params[key]);
  }).join('&');
  return `https://docs.google.com/spreadsheets/d/${spreadsheet.getId()}/export?${query}`;
}

// PDFとして保存する。
function savePDF(SpreadSheet_ID, fileName) {
  var spreadsheets = SpreadsheetApp.openById(SpreadSheet_ID);

  var output_url = createUrlForPdf(spreadsheets);

/** @type {string} ユーザーのOAuth2.0アクセストークン */
  const token = ScriptApp.getOAuthToken();
  // URLからblobデータを取得する
  const blob = UrlFetchApp.fetch(output_url, {headers: {'Authorization': 'Bearer ' + token}}).getBlob();

  var folder_id = getFolderIdBySpreadsheet(spreadsheets);

  // ファイル名を設定してフォルダにPDFファイルを出力する
  DriveApp.getFolderById(folder_id).createFile(blob.setName(fileName));
}

保存したPDFを印刷する

印刷にはEPSON製のプリンタで使えるEPSON Connectを使いました。APIでも呼び出せますが、今回は指定されたメールアドレスにデータを添付すると印刷できる機能を使いました。
GASでメールを送るのは簡単で、以下の手順で行います。

    var to = PRINT_MAIL_ADDRESS;
    var subject = "メールのタイトル";
    var body =""; 
    // blobに添付するPDFデータを入れる。生成した時に生成したものとか使うと簡単
    
    GmailApp.sendEmail(to,
                     subject,
                     body,
                     {attachments: blob});

まとめ

今回のPoCレベルのデモをくみ上げるのにかかった時間は実質10時間程度でした。
AWSやGCPを使うよりも簡単なものであればGASは非常に有効だなと改めて感じた次第です。

実はLINEBeaconも使いたかったのですが、挙動があまりにもわけわからなかったので使えませんでした。。。

Discussion