GAS

GASで完全サーバレスのWebアプリを作成する方法を徹底解説

thumbnail
n-mukineer
えぬ
えぬ

こんにちは、えぬ(@nmukineer)です!

GASを使うと面倒な環境構築やデプロイ準備をすることなく、誰でも簡単にWebアプリを作って公開することができます。今回作成したアプリは、名付けて「アミノ酸スコアチェッカー」。食品のアミノ酸スコアを手軽に調べることができます。

この記事の内容
  • GASを使ってサーバレスWebアプリを作る方法がわかる
  • GASでHTML, CSS, JavaScriptを分けて作成する方法がわかる
  • WebアプリとLINE Botの連携方法がわかる

本業でRuby on Railsを使ったWebアプリ製作をしているのですが、「環境構築」「デプロイ」この2点でつまづくことが多く、「環境構築不要で初心者でも簡単にデプロイまで持っていける方法はないのか!」など日々感じておりました。

なんと、GAS(Google Apps Script)を使えば完全サーバレス・環境構築不要で簡単にWebアプリを作ることが可能なんです!

この記事では実際に筆者が作成したWebアプリのコードを全公開します!(もちろんコピペOKです!)

今回作ったもの

作り方を説明する前に、今回製作したWebアプリの完成イメージを共有します!

えぬ
えぬ

ぜひ、遊んでみてください!

Webアプリの概要

紹介

アミノ酸スコアチェッカー(クリックするとアプリが開きます)

できること

アミノ酸スコアチェッカーの画面はこのようになっています。

「アミノ酸スコアチェッカー」の画面構成
  • 100gあたりのたんぱく質量の表示
    • 食品100gあたりに含まれるたんぱく質の量を表示しています。この量が多いほどたんぱく質豊富な食品であることがわかります。
  • アミノ酸スコアの表示
    • 文部科学省がまとめた「日本食品標準成分表2020年版(八訂)」の全食品データをから、アミノ酸スコアを計算して表示することができます。
    • アミノ酸スコアは通常、100を超えた場合も100とされるのですが、このアプリではあえて300まで表示できるようにしています。
  • 必須アミノ酸含有量のレーダーチャート表示
    • 上記の食品データから、9種類の必須アミノ酸がどのくらい含まれているかをレーダーチャートでグラフ表示することができます。
  • 食品一覧メニューの表示
    • 食品リストは18個の食品群に分かれており、それぞれの項目をタップするとアコーディオン式に開くメニューとしています。
  • LINE Bot連携
    • LINE Botとして友達追加すると、LINEメッセージでワード検索ができます。

作成手順

アミノ酸スコアチェッカーはざっくりと以下のような流れで作成していきました(詳細は次の章)。

  1. データを準備する(データベースの準備)
  2. データを取り出して計算できるようにする(バックエンド部分の実装)
  3. ページを作る(フロントエンド部分の実装)
    • HTMLで要素を表示する
      • 固定テキストを表示させる
      • GASスクリプトから変数を渡して表示させる
    • CSSでページの見た目を整える
    • JavaScriptでページに動きをつける
  4. LINE Botと連携してキーワード検索できるようにする(+α機能)
えぬ
えぬ

ここからは具体的な作成方法について説明していきます!

データを準備する(データベースの準備)

えぬ
えぬ

以下に細かいスプレッドシートの説明をしていますが、作成したスプレッドシートはこちらから閲覧できますので、そのまま使用していただいても大丈夫です!

アミノ酸スコアを算出するのに必要なデータは、文部科学省の「日本食品標準成分表2020年版(八訂)」のページから「・第2章第3表(データ) (Excel:301KB) 」ダウンロードします。

これをExcelまたはGoogle Spreadsheetで開くと、「第3表 アミノ酸組成によるたんぱく質 1 g 当たりのアミノ酸成分表」と書かれた表が見れます。複数のシートに分かれていますが、最初の「表全体」シートをそのまま新しいスプレッドシートにコピーしておきます。

第3表 アミノ酸組成によるたんぱく質 1 g 当たりのアミノ酸成分表

新しく作成して上記をコピーしておいたスプレッドシートを、GASから使いやすいように加工していきます。

  • A列:「itemNo
    • 「itemNo」は、いわゆるIDみたいなものだと考えてください。食品一つ一つに振られた固有の番号で、「食品成分データベース」(このデータベースは、文部科学省が開発したものであり、試験的に公開しているものです)の検索パラメータとして使われています。
    • 例えば、「卵類/鶏卵/全卵/生」の場合、食品群の番号は「12(卵類)」であり、食品番号は「12004」です。それをアンダースコア(”_”)でつなげたものに、さらに”_7″を付け加えたもの(12_12004_7)となります。
  • B列:「groupId
    • 「groupId」はC列の「食品群」を文字列から数字に変換したものです。
  • F列:「食品群名」
    • これは食品群番号を日本語化したもので、番号が1であれば「穀類」、2であれば「いも及びでん粉類」といったような分類となります。別シートに「groups」という名前でシートを作り対応表を用意します。そのテーブルをVLOOKUPでgroupIdと対応させれば日本語化ができます。
  • I列:「アミノ酸スコア」
    • アミノ酸スコアは9種類の必須アミノ酸と、酸評点パターン(文献)から計算できます。詳しい計算方法については以下の記事が参考になりましたのでリンクを載せておきます。
あわせて読みたい
アミノ酸スコアとは?計算式やスコア100の食品、プロテインスコアとの違いを解説
アミノ酸スコアとは?計算式やスコア100の食品、プロテインスコアとの違いを解説

データを取り出して計算できるようにする(バックエンド部分の実装)

データが準備できたら、実際にそのデータを使って処理を行うGASスクリプトを書いていきます。

スプレッドシートからのデータの取り出し

すべてのデータを一気に読み込み、オブジェクト化する

まずはスプレッドシートから必要なデータを一気に抜きだしてくるコードを書いていきます。

最初に、スプレッドシート関連の定数を定義していきます。LAST_ROW_INDEXはGASスクリプトを使って取得することも可能ではあるのですが、毎回取得しているとパフォーマンスが悪い&そんなに頻繁にデータを更新するわけでもないので、定数として定義しました。

// スプレッドシートID(GASプロジェクトのプロパティに記載しておいたものを呼び出す)
const SPREADSHEET_ID = PropertiesService.getScriptProperties().getProperty('SPREADSHEET_ID');
// ワークシート名
const SHEET_FOOD = 'foods';
// アミノ酸組成によるたんぱく質
const PROTEIN = "アミノ酸組成によるたんぱく質";
// スプレッドシートの列番号
const COLUMN_NUMBER = {
  itemNo: 1,
  groupId: 2,
  groupName: 6,
  name: 7,
  state: 8,
  score: 9,
  protein: 10,
  'イソロイシン': 11,
  'ロイシン': 12,
  'リシン': 13,
  '含硫アミノ酸': 16,
  '芳香族アミノ酸': 19,
  'トレオニン': 20,
  'トリプトファン': 21,
  'バリン': 22,
  'ヒスチジン': 23
}
// 最初にデータが始まる行
const FIRST_ROW_INDEX = 6;
// 最後の行番号
const LAST_ROW_INDEX = 802;
// 最後の列番号(GASで使うデータの最後の列まで)
const LAST_COLUMN_INDEX = COLUMN_NUMBER['ヒスチジン'];

次に、上記で定義した定数を使って、スプレッドシートからデータを読み込んでくる処理を書いていきます。

スプレッドシートIDから、シートにアクセスするための初期化を行います。

const activeSheet = SpreadsheetApp.openById(SPREADSHEET_ID);
const sheet = activeSheet.getSheetByName(SHEET_FOOD);

シートオブジェクトに対して、

sheet.getRange(行番号, 列番号, 行数, 列数).getValues()

とすることで、getRangeに指定した範囲のセルに対して一括で値を取得することができます。今回の場合以下のようにします。

// すべての食品情報をロード
const allSheetData = sheet.getRange(FIRST_ROW_INDEX, COLUMN_NUMBER.itemNo, LAST_ROW_INDEX - FIRST_ROW_INDEX + 1, LAST_COLUMN_INDEX).getValues();

スプレッドシートからデータを取り出す際は、できるだけ回数を少なくしましょう。小分けにして取り出すとその分APIリクエストが発行されるので、余計に時間がかかる原因となります。

上記のままでもよいのですが、allSheetDataの時点では配列として値が格納されているので、ちょっと扱いにくいです。

そこで、オブジェクトの形に変換する処理を追加します。この処理を回すことでオブジェクトの配列としてすべての食品データがallFoodsに格納されます。

// すべての食品情報をオブジェクトの形にまとめる
const allFoods = getFoodsFromSS();

function getFoodsFromSS() {
  return allSheetData.map(row => {
    let obj = {};
    Object.keys(COLUMN_NUMBER).forEach(key => {
      obj[key] = row[COLUMN_NUMBER[key] - 1];
    });
    return obj;
  });
}

/* allFoodsはこのような形式になっている
	[ { itemNo: '1_01002_7',
    groupId: 1,
    groupName: '穀類',
    name: 'あわ',
    state: '精白粒     ',
    score: 49,
    protein: 10.2,
    'イソロイシン': 47,
    'ロイシン': 150,
    'リシン': 22,
    '含硫アミノ酸': 59,
    '芳香族アミノ酸': 97,
    'トレオニン': 46,
    'トリプトファン': 21,
    'バリン': 58,
    'ヒスチジン': 26 },
  { itemNo: '1_01004_7',
    groupId: 1,
    groupName: '穀類',
    name: 'えんばく',
    state: 'オートミール     ',
    score: 113,
    protein: 12.2,
    'イソロイシン': 48,
    'ロイシン': 88,
    'リシン': 51,
    '含硫アミノ酸': 63,
    '芳香族アミノ酸': 100,
    'トレオニン': 41,
    'トリプトファン': 17,
    'バリン': 66,
    'ヒスチジン': 29 },
....
]
*/

食品群ごとにまとめる処理

つぎに、すべての食品データを、もっと扱いやすい形に整形します。食品には「食品群」という属性がありますので、食品群でひとまとまりにして取り出せるようにする関数を準備しておきます(後程、HTMLに値を渡す際に呼び出します)。

// 食品群ごとにまとめる
function foodsByGroup() {
  let obj = {};
  allFoods.forEach((food, index) => {
    if(!obj[`group${food.groupId}`]) {
      obj[`group${food.groupId}`] = [food];
    } else {
      obj[`group${food.groupId}`].push(food);
    }
  });
  return obj;
}

/* foodsByGroup()の戻り値は以下のようになる。
{
  group1: [
 { itemNo: '1_01002_7',
    groupId: 1,
    groupName: '穀類',
    name: 'あわ',
    state: '精白粒     ',
    score: 49,
    protein: 10.2,
    'イソロイシン': 47,
    'ロイシン': 150,
    'リシン': 22,
    '含硫アミノ酸': 59,
    '芳香族アミノ酸': 97,
    'トレオニン': 46,
    'トリプトファン': 21,
    'バリン': 58,
    'ヒスチジン': 26 },
  { itemNo: '1_01004_7',
    groupId: 1,
    groupName: '穀類',
    name: 'えんばく',
    state: 'オートミール     ',
    score: 113,
    protein: 12.2,
    'イソロイシン': 48,
    'ロイシン': 88,
    'リシン': 51,
    '含硫アミノ酸': 63,
    '芳香族アミノ酸': 100,
    'トレオニン': 41,
    'トリプトファン': 17,
    'バリン': 66,
    'ヒスチジン': 29 },
    ...
    ],
  group2: [
   ...

  ]
   ...
}
*/

すべての食品情報から特定の食品情報を検索する処理

便利関数として、findItem()関数を用意しておきます。引数にitemNoを指定すると、allFoodsの中からヒットする食品オブジェクトを返す関数です。

// itemNoから検索
function findItem(itemNo) {
  const food = allFoods.find(e => e.itemNo === itemNo);
  return food;
}

アミノ酸スコア含有量比率を取り出す

アミノ酸評点パターンに対して、各アミノ酸の量がどの程度の割合で入っているかを計算し、オブジェクトとして返す関数を定義します。この関数は、後程レーダーチャート用に使用されます。

一般的には100を超えた場合は100となるのですが、このアプリにおいてはあえて300まで表示ができるようにMAX_SCOREを300に設定しています。

また、このオブジェクトの値の最小値を取り出したものが「アミノ酸スコア」となるわけですが、アミノ酸スコアはすでにスプレッドシート上で計算しているため、そちらの値をそのまま抜き出して使うことにしました。

// アミノ酸評点パターン 2007 (mg/gたんぱく質)
const AMINO_ACID_SCORING_PATTERN_2007 = {
  'イソロイシン': 30,
  'ロイシン': 59,
  'リシン': 45,
  '含硫アミノ酸': 22,
  '芳香族アミノ酸': 38,
  'トレオニン': 23,
  'トリプトファン': 6,
  'バリン': 39,
  'ヒスチジン': 15
}
// 最大スコア
const MAX_SCORE = 300;

// アミノ酸評点パターンに対するアミノ酸含有量の比率を計算(レーダーチャート用)
function aminoPercentagesFromSS(itemNo) {
  const item = findItem(itemNo);
  const aminoObj = {}
  Object.keys(AMINO_ACID_SCORING_PATTERN_2007).forEach(key => {
    const amount = item[key];
    const ratio = Math.round(Number(amount) / AMINO_ACID_SCORING_PATTERN_2007[key] * 100.0);
    aminoObj[key] = (ratio > MAX_SCORE) ? MAX_SCORE : ratio;
  });
  return aminoObj;
}

/* aminoPercentagesFromSS()の戻り値の例
{ 'イソロイシン': 157,
  'ロイシン': 254,
  'リシン': 49,
  '含硫アミノ酸': 268,
  '芳香族アミノ酸': 255,
  'トレオニン': 200,
  'トリプトファン': 300,
  'バリン': 149,
  'ヒスチジン': 173 }
*/

Webアプリとして動作するための処理

Webアプリとして公開するため、doGet関数を用意します。これは、「WebアプリのURLにGETメソッドでHTTPリクエストを行った際に実行される」関数です。

Webアプリとして公開するための基本ステップは以下の記事にまとめました。

あわせて読みたい
GASを使ってたった5分でWebページを作成・公開する方法
GASを使ってたった5分でWebページを作成・公開する方法
あわせて読みたい
時短ワザも紹介!GASで作ったHTMLファイルにスクリプトレットを使って変数を渡す方法
時短ワザも紹介!GASで作ったHTMLファイルにスクリプトレットを使って変数を渡す方法
// 食品成分データベースのURLとクエリ
const BASE_URL = "https://fooddb.mext.go.jp/details/details.pl";
const MODES = {
  aminoEdible100: 1,          // アミノ酸-可食部100g
  aminoStandardNitrogen1: 2,  // アミノ酸-基準窒素1g
  aminoProtein1: 3            // アミノ酸-たんぱく質1g
}

// WebアプリのURL
const APP_URL = ScriptApp.getService().getUrl();

// ウェブアプリとして公開する用
function doGet(e) {
  const html = HtmlService.createTemplateFromFile('index');
  const itemNo = e.parameter.item_no;
  if(itemNo) {
    const food = findItem(itemNo);
    const score = food.score;
    html.score = (score > MAX_SCORE) ? MAX_SCORE : score;
    html.scoreJudge = aminoScoreJudge(score);
    html.foodName = `${food.name} ${food.state}`;
    html.itemNo = itemNo;
    html.radarElems = getRadarElems(itemNo);
    html.proteinAmount = food.protein;
  } else {
    html.foodName = '';
    html.score = ''
    html.scoreJudge = '';
    html.itemNo = '';
    html.radarElems = getRadarElems('');
    html.proteinAmount = '';
  }
  html.foods = foodsByGroup();
  html.url = APP_URL;
  return html.evaluate().addMetaTag('viewport', 'width=device-width, initial-scale=1').setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL); // iframeでの表示を許可;
}

// アミノ酸スコアの評価(CSSで色を付けるための情報)
function aminoScoreJudge(score) {
  if(score >= 100) {
    return 'perfect';
  }
  if(score >= 80) {
    return 'good';
  }
  if(score >= 60) {
    return 'medium';
  }
  if(score >= 40) {
    return 'poor';
  }
  return 'bad';
}

// レーダーチャート用
function getRadarElems(itemNo) {
  if(itemNo == '') {
    return Object.keys(AMINO_ACID_SCORING_PATTERN_2007).map(key => {
      return [
        key,
        AMINO_ACID_SCORING_PATTERN_2007[key],
        "アミノ酸評点パターン"
      ]
    });
  }
  const tmp = aminoPercentagesFromSS(itemNo);

  return Object.keys(tmp).map(key => {
      return [
        key,
        tmp[key],
        "必須アミノ酸含有量[%](※2)"
      ]
  });
}

doGet関数の引数eからは、リクエスト時のパラメータを拾い出すことができます。

const itemNo = e.parameter.item_no;

このように書くことで、リクエスト時のitem_noというクエリ(URLでは?item_no=hogehogeというように指定され、hogehogeが取り出せる)の値を取得することができます。

HTMLに値を渡すには、

const html = HtmlService.createTemplateFromFile('index');

LINE Botと連携してキーワード検索できるようにする(+α機能)

LINE Botと連携する方法は下記にまとめてありますので、そちらもご参照ください。

あわせて読みたい
【「今日晩御飯どうする?」を解決!】LINE Messaging APIとGASを使って晩御飯候補を決めてもらった
【「今日晩御飯どうする?」を解決!】LINE Messaging APIとGASを使って晩御飯候補を決めてもらった
// LINE APIの設定
const LINE_API_ACCESS_TOKEN = PropertiesService.getScriptProperties().getProperty('LINE_API_ACCESS_TOKEN');
const LINE_REPLY_URL = 'https://api.line.me/v2/bot/message/reply';

// LINE送信したときに実行される関数
function doPost(e) {
  const eventData = JSON.parse(e.postData.contents).events[0];
  const replyToken = eventData.replyToken;
  const userMessage = eventData.message.text;
  const messageType = eventData.message.type;
  let replyMessage = "テキストを送信してください";

  // textじゃなければリターン
  if(messageType != 'text') {
      reply(replyToken, replyMessage);
      return;
  }

  const keywords = splitKeywordStr(userMessage);
  let regexp = new RegExp(`^(?=.*${keywords.join(")(?=.*")})`);
  let matchItems = {};

  // 検索キーワードのAND条件で引っかかるものを最大3個取り出す。
  allFoods.some(food => {
    const str = `${food.groupName} ${food.name} ${food.state}`
  
    if(regexp.test(str)) {
      matchItems[`スコア【${food.score}】: ${food.name} ${food.state}`] = APP_URL + `?item_no=${food.itemNo}`;
    }
    if(Object.keys(matchItems).length >= 5) {
      return true;
    }
  });
  let replyBaseMessage;
  if(Object.keys(matchItems).length) {
    replyBaseMessage = `【${keywords}】で${Object.keys(matchItems).length}件が見つかりました(最大5件)\n`;
  } else {
    replyBaseMessage = `【${keywords}】は見つかりませんでした。ブラウザで開いてメニューを選択してください\n${APP_URL}`;
  }
  const replyUrls = Object.keys(matchItems).map(key => (`${key}↓\n${matchItems[key]}`));

  reply(replyToken, replyBaseMessage + replyUrls.join("\n"));
}

// キーワードをスペースで分割する
function splitKeywordStr(str) {
  const separator = / +| +/
  return str.split(separator);
}

// LINEへのリプライ
function reply(token, message) {
  const payload = {
    'replyToken': token,
    'messages': [{
        'type': 'text',
        'text': message
      }]
  };
  const options = {
    'payload' : JSON.stringify(payload),
    'method'  : 'POST',
    'headers' : {"Authorization" : "Bearer " + LINE_API_ACCESS_TOKEN},
    'contentType' : 'application/json'
  };
  UrlFetchApp.fetch(LINE_REPLY_URL, options);
}

GASスクリプト全体

ここまで紹介してきたコードをまとめたものがこちらです。コピペOKです!

// ===================================================
// スプレッドシートID(GASプロジェクトのプロパティに記載しておいたものを呼び出す)
const SPREADSHEET_ID = PropertiesService.getScriptProperties().getProperty('SPREADSHEET_ID');
const SHEET_FOOD = 'foods';
// アミノ酸組成によるたんぱく質
const PROTEIN = "アミノ酸組成によるたんぱく質";
// スプレッドシートの列番号
const COLUMN_NUMBER = {
  itemNo: 1,
  groupId: 2,
  groupName: 6,
  name: 7,
  state: 8,
  score: 9,
  protein: 10,
  'イソロイシン': 11,
  'ロイシン': 12,
  'リシン': 13,
  '含硫アミノ酸': 16,
  '芳香族アミノ酸': 19,
  'トレオニン': 20,
  'トリプトファン': 21,
  'バリン': 22,
  'ヒスチジン': 23
}
// 最初にデータが始まる行
const FIRST_ROW_INDEX = 6;
// 最後の行番号
const LAST_ROW_INDEX = 802;
// 最後の列番号
const LAST_COLUMN_INDEX = COLUMN_NUMBER['ヒスチジン'];

// ===================================================
// スプレッドシートのロード
const activeSheet = SpreadsheetApp.openById(SPREADSHEET_ID);
const sheet = activeSheet.getSheetByName(SHEET_FOOD);

// すべての食品情報をロード
const allSheetData = sheet.getRange(FIRST_ROW_INDEX, COLUMN_NUMBER.itemNo, LAST_ROW_INDEX - FIRST_ROW_INDEX + 1, LAST_COLUMN_INDEX).getValues();
const allFoods = getFoodsFromSS();

// すべての食品情報をオブジェクトの形にまとめる
function getFoodsFromSS() {
  return allSheetData.map(row => {
    let obj = {};
    Object.keys(COLUMN_NUMBER).forEach(key => {
      obj[key] = row[COLUMN_NUMBER[key] - 1];
    });
    return obj;
  });
}

// 食品群ごとにまとめる
function foodsByGroup() {
  let obj = {};
  allFoods.forEach((food, index) => {
    if(!obj[`group${food.groupId}`]) {
      obj[`group${food.groupId}`] = [food];
    } else {
      obj[`group${food.groupId}`].push(food);
    }
  });
  return obj;
}

// itemNoから検索
function findItem(itemNo) {
  const food = allFoods.find(e => e.itemNo === itemNo);
  return food;
}

// ===================================================
// アミノ酸評点パターン 2007 (mg/gたんぱく質)
// https://www.kyoiku-tosho.co.jp/data/correct/teisei_colorchart.pdf
const AMINO_ACID_SCORING_PATTERN_2007 = {
  'イソロイシン': 30,
  'ロイシン': 59,
  'リシン': 45,
  '含硫アミノ酸': 22,
  '芳香族アミノ酸': 38,
  'トレオニン': 23,
  'トリプトファン': 6,
  'バリン': 39,
  'ヒスチジン': 15
}
// 最大スコア
const MAX_SCORE = 300;

// アミノ酸評点パターンに対するアミノ酸含有量の比率を計算(レーダーチャート用)
function aminoPercentagesFromSS(itemNo) {
  const item = findItem(itemNo);
  const aminoObj = {}
  Object.keys(AMINO_ACID_SCORING_PATTERN_2007).forEach(key => {
    const amount = item[key];
    const ratio = Math.round(Number(amount) / AMINO_ACID_SCORING_PATTERN_2007[key] * 100.0);
    aminoObj[key] = (ratio > MAX_SCORE) ? MAX_SCORE : ratio;

  });
  console.log(aminoObj);
  return aminoObj;
}

// ================ WEBアプリとして公開する用 =============

// 食品成分データベースのURLとクエリ
const BASE_URL = "https://fooddb.mext.go.jp/details/details.pl";
const MODES = {
  aminoEdible100: 1,          // アミノ酸-可食部100g
  aminoStandardNitrogen1: 2,  // アミノ酸-基準窒素1g
  aminoProtein1: 3            // アミノ酸-たんぱく質1g
}

// WebアプリのURL
const APP_URL = ScriptApp.getService().getUrl();

// ウェブアプリとして公開する用
function doGet(e) {
  console.log(e);
  const html = HtmlService.createTemplateFromFile('index');
  const itemNo = e.parameter.item_no;
  if(itemNo) {
    const food = findItem(itemNo);
    const score = food.score;
    html.score = (score > MAX_SCORE) ? MAX_SCORE : score;
    html.scoreJudge = aminoScoreJudge(score);
    html.foodName = `${food.name} ${food.state}`;
    html.itemNo = itemNo;
    html.radarElems = getRadarElems(itemNo);
    html.proteinAmount = food.protein;
  } else {
    html.foodName = '';
    html.score = ''
    html.scoreJudge = '';
    html.itemNo = '';
    html.radarElems = getRadarElems('');
    html.proteinAmount = '';
  }
  html.foods = foodsByGroup();
  html.url = APP_URL;
  return html.evaluate().addMetaTag('viewport', 'width=device-width, initial-scale=1').setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL); // iframeでの表示を許可;
}

// アミノ酸スコアの評価
function aminoScoreJudge(score) {
  if(score >= 100) {
    return 'perfect';
  }
  if(score >= 80) {
    return 'good';
  }
  if(score >= 60) {
    return 'medium';
  }
  if(score >= 40) {
    return 'poor';
  }
  return 'bad';
}

// レーダーチャート用
function getRadarElems(itemNo) {
  if(itemNo == '') {
    return Object.keys(AMINO_ACID_SCORING_PATTERN_2007).map(key => {
      return [
        key,
        AMINO_ACID_SCORING_PATTERN_2007[key],
        "アミノ酸評点パターン"
      ]
    });
  }
  const tmp = aminoPercentagesFromSS(itemNo);

  return Object.keys(tmp).map(key => {
      return [
        key,
        tmp[key],
        "必須アミノ酸含有量[%](※2)"
      ]
  });
}

// ============= LINE BOT化 =================

// LINE APIの設定
const LINE_API_ACCESS_TOKEN = PropertiesService.getScriptProperties().getProperty('LINE_API_ACCESS_TOKEN');
const LINE_REPLY_URL = 'https://api.line.me/v2/bot/message/reply';

// LINE送信したときに実行される関数
function doPost(e) {
  const eventData = JSON.parse(e.postData.contents).events[0];
  const replyToken = eventData.replyToken;
  const userMessage = eventData.message.text;
  const messageType = eventData.message.type;
  let replyMessage = "テキストを送信してください";

  // textじゃなければリターン
  if(messageType != 'text') {
      reply(replyToken, replyMessage);
      return;
  }

  const keywords = splitKeywordStr(userMessage);
  let regexp = new RegExp(`^(?=.*${keywords.join(")(?=.*")})`);
  let matchItems = {};

  // 検索キーワードのAND条件で引っかかるものを最大3個取り出す。
  allFoods.some(food => {
    const str = `${food.groupName} ${food.name} ${food.state}`
  
    if(regexp.test(str)) {
      matchItems[`スコア【${food.score}】: ${food.name} ${food.state}`] = APP_URL + `?item_no=${food.itemNo}`;
    }
    if(Object.keys(matchItems).length >= 5) {
      return true;
    }
  });
  let replyBaseMessage;
  if(Object.keys(matchItems).length) {
    replyBaseMessage = `【${keywords}】で${Object.keys(matchItems).length}件が見つかりました(最大5件)\n`;
  } else {
    replyBaseMessage = `【${keywords}】は見つかりませんでした。ブラウザで開いてメニューを選択してください\n${APP_URL}`;
  }
  const replyUrls = Object.keys(matchItems).map(key => (`${key}↓\n${matchItems[key]}`));

  reply(replyToken, replyBaseMessage + replyUrls.join("\n"));
}

// キーワードをスペースで分割する
function splitKeywordStr(str) {
  const separator = / +| +/
  return str.split(separator);
}

// LINEへのリプライ
function reply(token, message) {
  const payload = {
    'replyToken': token,
    'messages': [{
        'type': 'text',
        'text': message
      }]
  };
  const options = {
    'payload' : JSON.stringify(payload),
    'method'  : 'POST',
    'headers' : {"Authorization" : "Bearer " + LINE_API_ACCESS_TOKEN},
    'contentType' : 'application/json'
  };
  UrlFetchApp.fetch(LINE_REPLY_URL, options);
}

ページを作る(フロントエンド部分の実装)

HTMLで要素を表示する

GASスクリプト上でindex.htmlというファイルを作ります。HTMLはこのようになっています。

<!DOCTYPE html>
<html>
  <head>
    <base target="_top">
    <?!= HtmlService.createHtmlOutputFromFile('css').getContent(); ?>
  </head>
  <body>
    <div class="sticky-top">
      <div class="titleArea">
        <div class="title">アミノ酸スコアチェッカー</div>
      </div>
      <div class="headerArea">
        <span class="followButton">
          <a href="https://twitter.com/nmukineer?ref_src=twsrc%5Etfw" class="twitter-follow-button" data-show-count="false">Follow @nmukineer</a><script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>
        </span>
        <span class="headerText"><a href="https://n-mukineer.com" target="_blank">Blog</a></span>
        <span class="headerText"><a href="https://liff.line.me/1645278921-kWRPP32q/?accountId=101fanoe" target="_blank">LINEでアプリを使う</a></span>
      </div>
    </div>
    <div class="container">
      <script type="text/javascript" src="https://www.gstatic.com/charts/loader.js"></script>
      <? if(itemNo != '') { ?>
        <div class="aminoScorePreText"><?= foodName ?></div>
        <div class="protein">
          <span>アミノ酸組成によるたんぱく質(100gあたり) </span>
          <span class="proteinAmount"><?= proteinAmount ?></span>
          <span > g</span>
        </div>
        <div class="aminoScorePreTextSmall">アミノ酸スコア(※1)</div>
        <div class="aminoScore__<?= scoreJudge ?>"><?= score ?></div>
        <div id="chart-area" class="js-radarChart resizeimage"></div>
        <div class="seeDetail">詳細は<a href=" https://fooddb.mext.go.jp/details/details.pl?ITEM_NO=<?= itemNo ?>&MODE=0" target="_blank">食品成分データベース(※3)</a>をご参照ください</div>
        <div class="supplement">※1 通常100が最大ですが300まで表示できるようにしています</div>
        <div class="supplement">※2 2007年改訂アミノ酸評点パターン(18歳以上)、及び日本食品標準成分表2020年版(八訂)から引用したデータをもとに算出</div>
        <div class="supplement">※3 このデータベースは、文部科学省が開発したものであり、試験的に公開しているものです</div>
        <br>
        <br>
      <? } ?>
      <div class="menu">
        <h3>食品を選ぶ(タップでメニューが開きます)</h3>
        <? groups = Object.keys(foods) ?>
        <? groups.forEach((group, index) => { ?>
          <div class="js-accordion__parent"><?= `- ${String(index + 1).padStart(2, '0')}. ${foods[group][0].groupName}` ?></div>
          <ul>
          <? foods[group].forEach(food => { ?>
            <li class="js-accordion__children"><a href="<?= url ?>?item_no=<?= food.itemNo ?>"><?= `【${food.score}点】${food.name} ${food.state}`?></a></li>
          <? }); ?>
          </ul> 
        <? }); ?>
      </div>
    </div>
    <?!= HtmlService.createHtmlOutputFromFile('js').getContent(); ?>
  </body>
</html>


<script type="text/javascript">
  $(function() {
    // 親メニュー処理
    $('.js-accordion__parent').click(function() {
      $(this).next('ul').slideToggle('fast');
    });

    // 子メニュー処理
    $('.js-accordion__children').click(function(e) {
      // メニュー表示/非表示
      $(this).children('ul').slideToggle('fast');
      e.stopPropagation();
    });
  });

  google.charts.load('upcoming', {'packages': ['vegachart']}).then(loadCharts);
  
  // スクリプトレットを使って変数をGASから受け取る
  // シングルコートじゃないとダメっぽい
  const radarElems = JSON.parse('<?= JSON.stringify(radarElems) ?>');

  function loadCharts() {
    addChart(radarElems[0][2], radarElems, "#eb6ea5");
  };

  function addChart(title, data, color) {
    const dataTable = new google.visualization.DataTable();
    dataTable.addColumn({type: 'string', 'id': 'key'});
    dataTable.addColumn({type: 'number', 'id': 'value'});
    dataTable.addColumn({type: 'string', 'id': 'category'});
    dataTable.addRows(data);
    const options = {
      'vega': {
        "$schema": "https://vega.github.io/schema/vega/v5.json",
        "width": 350,
        "height": 400,
        "autosize": "none",
        "title": {
          "text": title,
          "anchor": "middle",
          "fontSize": 20,
          "dy": -10,
          "dx": -100,
          "subtitle": "300以上は300と表示"
        },
        "signals": [
          {"name": "radius", "update": "120"}
        ],
        "data": [
          {
            "name": "table",
            "source": "datatable",
          },
          {
            "name": "keys",
            "source": "table",
            "transform": [
              {
                "type": "aggregate",
                "groupby": ["key"]
              }
            ]
          }
        ],
        "scales": [
          {
            "name": "angular",
            "type": "point",
            "range": {"signal": "[-PI, PI]"},
            "padding": 0.5,
            "domain": {"data": "table", "field": "key"}
          },
          {
            "name": "radial",
            "type": "linear",
            "range": {"signal": "[0, radius]"},
            "zero": true,
            "nice": false,
            "domain": [0,300],
          }
        ],
        "encode": {
          "enter": {
            "x": {"signal": "width/2"},
            "y": {"signal": "height/2 + 20"}
          }
        },
        "marks": [
          {
            "type": "group",
            "name": "categories",
            "zindex": 1,
            "from": {
              "facet": {"data": "table", "name": "facet", "groupby": ["category"]}
            },
            "marks": [
              {
                "type": "line",
                "name": "category-line",
                "from": {"data": "facet"},
                "encode": {
                  "enter": {
                    "interpolate": {"value": "linear-closed"},
                    "x": {"signal": "scale('radial', datum.value) * cos(scale('angular', datum.key))"},
                    "y": {"signal": "scale('radial', datum.value) * sin(scale('angular', datum.key))"},
                    "stroke": {"value": color},
                    "strokeWidth": {"value": 1.5},
                    "fill": {"value": color},
                    "fillOpacity": {"value": 0.1}
                  }
                }
              },
              {
                "type": "text",
                "name": "value-text",
                "from": {"data": "category-line"},
                "encode": {
                  "enter": {
                    "x": {"signal": "datum.x + 14 * cos(scale('angular', datum.datum.key))"},
                    "y": {"signal": "datum.y + 14 * sin(scale('angular', datum.datum.key))"},
                    "text": {"signal": "format(datum.datum.value,'1')"},
                    "opacity": {"signal": "datum.datum.value > 0.01 ? 1 : 0"},
                    "align": {"value": "center"},
                    "baseline": {"value": "middle"},
                    "fontWeight": {"value": "bold"},
                    "fill": {"value": color},
                  }
                }
              }
            ]
          },
          {
            "type": "rule",
            "name": "radial-grid",
            "from": {"data": "keys"},
            "zindex": 0,
            "encode": {
              "enter": {
                "x": {"value": 0},
                "y": {"value": 0},
                "x2": {"signal": "radius * cos(scale('angular', datum.key))"},
                "y2": {"signal": "radius * sin(scale('angular', datum.key))"},
                "stroke": {"value": "lightgray"},
                "strokeWidth": {"value": 1}
              }
            }
          },
          {
            "type": "text",
            "name": "key-label",
            "from": {"data": "keys"},
            "zindex": 1,
            "encode": {
              "enter": {
                "x": {"signal": "(radius + 11) * cos(scale('angular', datum.key))"},
                "y": [
                  {
                    "test": "sin(scale('angular', datum.key)) > 0",
                    "signal": "5 + (radius + 11) * sin(scale('angular', datum.key))"
                  },
                  {
                    "test": "sin(scale('angular', datum.key)) < 0",
                    "signal": "-5 + (radius + 11) * sin(scale('angular', datum.key))"
                  },
                  {
                    "signal": "15 + (radius + 11) * sin(scale('angular', datum.key))"
                  }
                ],
                "text": {"field": "key"},
                "align":
                  {
                    "value": "center"
                  },
                "baseline": [
                  {
                    "test": "scale('angular', datum.key) > 0", "value": "top"
                  },
                  {
                    "test": "scale('angular', datum.key) == 0", "value": "middle"
                  },
                  {
                    "value": "bottom"
                  }
                ],
                "fill": {"value": "black"},
                "fontSize": {"value": 12}
              }
            }
          },
          {
            "type": "line",
            "name": "twenty-line",
            "from": {"data": "keys"},
            "encode": {
              "enter": {
                "interpolate": {"value": "linear-closed"},
                "x": {"signal": "0.2 * radius * cos(scale('angular', datum.key))"},
                "y": {"signal": "0.2 * radius * sin(scale('angular', datum.key))"},
                "stroke": {"value": "lightgray"},
                "strokeWidth": {"value": 1}
              }
            }
          },
          {
            "type": "line",
            "name": "fourty-line",
            "from": {"data": "keys"},
            "encode": {
              "enter": {
                "interpolate": {"value": "linear-closed"},
                "x": {"signal": "0.4 * radius * cos(scale('angular', datum.key))"},
                "y": {"signal": "0.4 * radius * sin(scale('angular', datum.key))"},
                "stroke": {"value": "lightgray"},
                "strokeWidth": {"value": 1}
              }
            }
          },
          {
            "type": "line",
            "name": "sixty-line",
            "from": {"data": "keys"},
            "encode": {
              "enter": {
                "interpolate": {"value": "linear-closed"},
                "x": {"signal": "0.6 * radius * cos(scale('angular', datum.key))"},
                "y": {"signal": "0.6 * radius * sin(scale('angular', datum.key))"},
                "stroke": {"value": "lightgray"},
                "strokeWidth": {"value": 1}
              }
            }
          },
          {
            "type": "line",
            "name": "eighty-line",
            "from": {"data": "keys"},
            "encode": {
              "enter": {
                "interpolate": {"value": "linear-closed"},
                "x": {"signal": "0.8 * radius * cos(scale('angular', datum.key))"},
                "y": {"signal": "0.8 * radius * sin(scale('angular', datum.key))"},
                "stroke": {"value": "lightgray"},
                "strokeWidth": {"value": 1}
              }
            }
          },
          {
            "type": "line",
            "name": "outer-line",
            "from": {"data": "radial-grid"},
            "encode": {
              "enter": {
                "interpolate": {"value": "linear-closed"},
                "x": {"field": "x2"},
                "y": {"field": "y2"},
                "stroke": {"value": "lightgray"},
                "strokeWidth": {"value": 1}
              }
            }
          }
        ]
      }
    };

    const elem = document.createElement("div");
    elem.setAttribute("style", "display: inline-block; width: 350px; height: 400px; padding: 0px;");

    const chart = new google.visualization.VegaChart(elem);
    chart.draw(dataTable, options);

    document.getElementById("chart-area").appendChild(elem);
  }

</script>

レーダーチャートの描画に関しては、こちらの記事を参考にさせていただきました。サイズや目盛りなど、作りたいアプリに合わせて調整しています。

Google Charts で レーダーチャートをサクッと作る
Google Charts で レーダーチャートをサクッと作る

CSSでページの見た目を整える

GASプロジェクト上でcss.htmlというファイルを作成します。

<style>
ul {
  display: none;
}

.js-accordion__parent {
  cursor: pointer;
}

.js-accordion__children {
  cursor: pointer;
}

.titleArea {
  //height: 150px;
  width: 100%; 
  background-color: #4864cc;
  color: white;
  padding: 10px;
}
.title {
  font-size: 20px;
  color: yellow;
  margin-left: 20px;
  font-weight: bold;
}
.subTitle {
  font-size: 15px;
  margin-left: 20px;
}

.headerArea {
  height: 30px; 
  background-color: #b4e4e4;
}
.headerText {
  margin-left: 10px;
  display: inline-block;
  vertical-align: middle;
}
.followButton {
  padding-top: 5px;
  margin-left: 10px;
  display: inline-block;
  vertical-align: middle;
}
//100点以上
.aminoScore__perfect {
  font-size: 100px;
  text-align: center;
  color: fuchsia;
}
// 80~99点
.aminoScore__good {
  font-size: 100px;
  text-align: center;
  color: lime;
}
// 60~79
.aminoScore__medium {
  font-size: 100px;
  text-align: center;
  color: green;
}
// 40~59
.aminoScore__poor {
  font-size: 100px;
  text-align: center;
  color: teal;
}
// 0~39
.aminoScore__bad {
  font-size: 100px;
  text-align: center;
  color: navy;
}

.seeDetail {
  text-align: center;
}
.supplement {
  text-align: center;
  font-size: 14px;
  margin: 5px
}

.resizeimage img { width: 100%; }
.aminoScorePreText {
  font-size: 30px;
  text-align: center;
  margin-top: 20px;
  margin-bottom: 15px;
  color: navy;
  font-weight: bold;
}
.aminoScorePreTextSmall {
  font-size: 24px;
  text-align: center;
  font-weight: bold;
}

.menu {
  margin-left: 10px;
  font-size: 18px;
}

.protein {
  text-align: center;
  font-size: 18px;
  margin-bottom: 10px;
}

.proteinAmount {
  font-size: 40px;
  font-weight: bold;
  color: blue;
}

.js-radarChart {
  text-align: center;
}

</style>

<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh" crossorigin="anonymous">

JavaScriptでページに動きをつける

GASプロジェクト上でjs.htmlというファイルを作成します。

以下のCDNを読み込ませます。

<script src="https://code.jquery.com/jquery-3.5.1.min.js" integrity="sha256-9/aliU8dGd2tb6OSsuzixeV4y/faTqgFtohetphbbj0=" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js" integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo" crossorigin="anonymous"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/js/bootstrap.min.js" integrity="sha384-wfSDF2E50Y2D1uUdj0O3uMBJnjuUD4Ih7YwaYd1iqfktj0Uod8GCExl3Og8ifwB6" crossorigin="anonymous"></script>

まとめ

GASを使って完全サーバレスのWebアプリを作成する方法を紹介しました。

環境構築が不要で、すぐにデプロイまでできるので、簡単なアプリをサクッと作りたい場合は非常に相性がよさそうですね。

お読みいただきありがとうございました!

このブログを書いている人
えぬ
えぬ
N日後にムキムキになるエンジニア
WebアプリエンジニアとしてIoTシステムを開発中。30代折り返し。 趣味(モノづくり、プログラミング、筋トレ)や子育てのことを主に記事にします。 TOEIC: 900点/第一級陸上無線技術士/第3種電気主任技術者/技術士一次試験合格/基本情報技術者/第2種電気工事士/デジタル技術検定2級(情報・制御)
記事URLをコピーしました