「開発言語は日本語で」GPT Builderでイベント検索カスタムAIを作ってGPTsに公開!

GPTs公開中

AI(特にChatGPT)の進化がオモシロイ!

1年前、OpenAIのAPIでボケてのLINEアプリを作った。その後の2023/11にGPTs(GPTストア)が誕生。誰でも簡単にChatGPTアプリを作って公開できるようになった。

それならやってみよう!ってことでさっそくGPTsにカスタムAIをリリースしてみた。
↑の画像にあるように、ChatGPTユーザなら左メニューから「GPTを探す」→「イベント」を検索すると「イベントニュース」が出てくる。(現時点で課金ユーザのみ)

動いている動画はこちら。自然な言葉で質問できてすぐに回答が来る。(コードも表示するデバッグモード。ほんとにリアルタイムにコードを生成しててオモシロイ!)

開発の流れを紹介

開発の流れをさらっと紹介する。もはやプログラミング言語は必要はない。日本語で簡単にオリジナルAIが作れる時代。実際やってみて、こんなに簡単にできるのかっ!っていう部分と、意外と大変、、という部分があったのであわせて解説しよう。

イベントニュースのデータを活用

今回つくるのはイベント検索AI。ということで当社が運営する(これまたGPTベースの)サービス「イベントニュース」のデータを使う。
イベントニュース|今日行けるイベントを検索|東京,大阪,名古屋,・・全国の情報を掲載

イベントニュースとは、ネットから日本全国の1万件以上のイベントデータを収集。地域や日付から検索したり、AIが新しいイベントをオススメしてくれるサービス。

GPTsリリースまでの大まかな流れ

GPTsはGPTのAppStoreのようなもの。誰でも簡単に自作AIがアップできる。(ただしOpenAIに課金している必要あり)。

リリースまでの大まかな流れはこんな感じ

①ChatGPTからマイGPTを作成する
②タイトルやアイコンなど基本設定
③イベントデータ(CSV)のアップロード
④指示(Instruction)をひたすら編集してチューニング
⑤GPTsへのリリース

リリースまでとにかく簡単。唯一時間がかかるのは④のInstructionの編集。いろいろと試行錯誤して結果的に20時間ぐらいかかった。とはいえ3日程度で1つのサービスが作れたので、簡単は簡単。それぞれの工程をざっと説明する。

①ChatGPTからマイGPTを作成する

まずはマイGPTを作ろう、ということで、左下のアカウント設定から「マイGPT」を選び、「create a GPT」から新しいカスタムAIを作成します。

MyGPTからカスタムAIを作成する

②タイトルやアイコンなど基本設定

カスタムAIの基本的な設定。GPT Builderというツールで、ChatGPTと対話する中で設定可能。また、タイトルなどからアイコンも自動生成してくる。

GPT Builderで基本設定

が、最初は便利だと思ったけど、普通に右のタブのConfigure(構成)のメニューからアイコンやタイトルを指定できるので、そっちから直接やったほうが楽で分かりやすい。左でいろいろ編集して、右のフレームではその場でチャットのデモを確認できる。分かりやすいUI。

GPT Builderの構成

③CSVデータのアップロード

次に、今回の検索対象データとなるcsvをアップする。(ファイルの形式はpdfやワード等も対応してるらしい。20ファイルまでで、1ファイル512MB以内。)

そして、csvから自動的にデータ抽出したり統計、変換などの処理をするために、コードインタープリターを有効に。AIが会話のみならず、プログラミング自体も自動生成してデータ処理してくれる。

csv uploadとcode interpriterの設定

カスタムAIといってもChatGPT自体の追加学習が出来るわけではない。csv等のデータを別途保存しておいて、それをGPTが読み込み・処理して検索結果を抽出する形。今のところ独自AIチャットをつくるのに、このやり方が一番確実で有効な手法っぽい。
※ちなみに、そのようなデータを読ませておいてAIが検索して結果を生成する手法をRAG(Retrieval-Augmented Generation:検索拡張生成)という。

※さらにちなみに、ファインチューニング(fine-tuning)という形で追加学習をすることは現状でも出来る。ただし、これはデータそのものを学習する訳ではなく、回答の優先度とか方言的なものとか、回答志向を学習するものなので今回のような用途ではやはりRAGがベストとなる。(将来的に、実際のデータ自体の追加学習も出来るようになると思われる。)

④指示(Instruction)をひたすら試行錯誤

いよいよ開発の本丸とも言えるInstructionの編集。そもそもChatGPTは賢いので、実はここまでのステップで、もうカスタムAIとしては機能する。こんなイベントはあるか?とか聞くとcsvから抽出してくれる。ただ、そのままだと、その検索過程が甘くて結果が少なかったり、表示結果が見にくかったり、間違った情報を出したりする。そこで、Instructionにいろいろ記述しておくことが大事となる。

例えば今回指示したこと
・日本語の簡潔かつフランクな言葉で話して
・なるべくデータがヒットするような工夫して
・日付や地域が見やすい結果表示形式
・リンク先はeventnews上のURLを採用して
・URLや期間を間違わないよう注意
等々だ

プロンプトに指示してもそれがどの程度ワークするか度合いがつかみにくかったする。そして同じプロンプトでも違う結果が返ってきたり、思いの外ここに苦戦する。

20時間の試行錯誤の結果、落ち着いたのが下記の指示コードだ。


#カスタムGPTの概要
あなたはとっても親切で優秀なデータ処理のプロです。
日本語だけ使います。
〜だよね!〜かな?などフレンドリーで簡潔に喋ります。

#イベントをオススメする流れ
まずユーザが入力したら、オッケーちょっとまってね!と即答してコードインタープリターでcsvファイルの中身を検索します。
入力したワードにGWや夏休みや次の週末など期間の指定があれば期間絞り込みに使います。
地名が入力されたら都道府県に変換して検索。
イベントの特徴を表すキーワードは類似ワードを7個以上生成して類似検索。
結果は閲覧回数順にソートします。最大8件まで表示します。

#CSVの項目名
event_name:イベント名
start_period:開催開始日
end_period:開催終了日
event_period:開催期間
description:紹介文
views:閲覧回数
prefectures:都道府県
eventnews_url:イベントのURL
eventnews_urlを取得する時はpandasの処理で文字列を省略させないように、pd.set_option('display.max_colwidth',None)にセットします。

都道府県のデータは欠損がありえるので、欠損はあらかじめ無視してください


#結果の表示形式
回答形式は下記の形式のテキストで構成します。

イベント名(event_name) <開催期間(event_period)> 都道府県(prefectures)
紹介文(description)
イベントのURL(eventnews_url)

イベント名は太字で記述。
開催期間はcsvのデータ(event_period)を変更せずにそのまま表示。
紹介文は要約せずすべての文章を表示。
イベントのURLはリンクを貼らないで、文字列を直接表記します。(https//eventnews.ai/?uid=???????????)
イベントとイベントの間は一行の空白行を入れます
もし、データが一回も見つからなかった場合、そんなことは基本ありえないので探し方を変えてもう一度試します。

#結果の表示後
その後に、
ワクワクするイベント見つかった?
URLはクリックできない仕様だから、コピペして使ってね。
もっと詳しく調べるなら

eventnews.ai(リンクを貼る)

にアクセスしてみてね。
他にはどんなイベント探してる?

などと聞いて次の会話をつなげます

たかだか50行程度の指示文。これにいきつくまでにいろいろ試したり方向転換したりと、結果20時間を要した。ちゃんと明確に指示しても、実行してくれなかったりする。あるときは指示通りいくが、同じ指示のはずなのにある時はうまくいかなかったり、気まぐれだ。

試行錯誤して間違いは減っていくが、それでもたまになぜか間違えたりする。そこにロジックはないように見える。これには、設計した通りにコードが実行されることに慣れきってきたエンジニアとしては結構あせり、ときには絶望する笑。

⑤GPTsへのリリース!

ひと通り動作確認してOKだったらいよいよGPTsへリリース。(身内にだけ公開したい場合はURLでシェア、なども可能)

これも簡単ですぐに「GPTを探す」の検索にもヒットするようになった

GPTsに公開する

ハマったポイント・注意点

これから挑戦する人のために、ハマったポイントをいくつか紹介しよう。

★Teamプランにアップグレード必須
2024/5現時点では、GPTの会話数は限られている。それと同時にGPT Builderでのビルド件数も限られている。最初は個人課金プランでやっていたが、1時間も使ってると制限を超えたのでもう2時間は触れません・・、などとなってしまう。仕方なく1人なのにTeamプラン(要は2人分の課金)にアップグレードした。Teamプランで使っている限りは、バリバリ会話しても制限にひっかかることは滅多になくなった。(1度だけあったが)

★表示例を書きすぎても逆にバグる?
たとえば日付表示など、「2025/4/1 のような形式で表示して」などと例を書いたところ、その4/1が影響を与えてたのか間違った日付を出したり、そのまま4/1と表示したりする。逆に例は書かず 「/で区切ってください」と指示しても間違えたりもする。まさに試行錯誤。

★文字列を勝手に短縮しちゃったり
URLの文字列「uid=2fewkwfffee」のようなパラメータは当たり前だが1文字でも間違えたらバグとなる。GPTはよかれと思ってなのか、勝手に文字列を短縮したりすることがある。それは絶対にやめてとお願いしておこう。

★外部リンクがNG
外部ページにリンクできない。URLを自分でコピペして貼り付けてね、などという面倒な作業を強いることに。。ちなみにトップドメイン(例えばhttps://eventnews.ai/ )ならリンクできるが、下層ページがNG。これはセキュリティ対策のひとつで、実行結果をハックして外部サイトを攻撃させたりしないようにOpenAIが制限しているのだろう。

★スマホだと表の中身はコピペNG
当初は見やすいようにテーブル・表形式で結果を出力していた。ただ、スマホで見たときにURLコピペができなかった。。仕方なくテキストをられつする今の形に落ち着いた。
eventtable

★日付を間違える。「〜」が苦手?
日付がなかなか正確にならない。csvのデータをそのまま表示するだけなのに・・。ここに1番ハマった。今でもたまに間違える。たぶんだが、「〜」や「/」などを使った日本独自の日付の扱いが苦手なのかも。〜を使わないようにするなど試行錯誤でなんとか精度が上がった。

★GPT4oが出てバグが発生!
開発が一段落したあとに、「GPT4o」が発表された(5/14頃)。ビックリすることに、GPT4oで賢くなったことに伴うバグが発生した。なんともオモシロイ現象だが、賢くなりすぎてcsvを検索せずに即興でそれっぽい答えを返すようになったのだった笑。これは「ちゃんとcsvから探すように」と明確に指示して解決。
その時の実行結果がこちら。「神奈川のSUMMER MUSIC FES」なんて実は存在しない笑
GPT4oで発生したバグ

更に、GPT4oの新機能なのか、自動で結果をtable出力するようになった。その結果をcsvダウンロードすることも出来る。これは便利なのでそのまま活用。

GPTのtable

GPTカスタムAI開発をして感じたこと

最後に、今回ChatGPTでカスタムAIを作った感想など。

☆これは新しい開発体験

なんだかんだでイベント検索アプリをプロンプトの指示だけで3日で作れてしまった。課題・限界などを感じたのも事実だが、この先いろいろな進化・応用があるように思える。新たな開発スタイルとしての扉が開いたように想う。

☆AIは忠実&ものわかりが悪いエンジニア

人間なら何度も指示が変わったりしたら嫌にもなろうものだが、AIは何十時間でもじっくり付き合ってくれる。真面目でいいやつだ。

ただものわかりはかなり悪い。わざわざそこまで言わなければいけないのか、という気持ちになる。たまに「その結果はおかしい、他のデータも見てもう一回やってみて」などと指示すると、本当にもう一度やってみて、「やっぱりダメでした」となったり、たまに「あ、できました!」もあったりする。まあその体験自体が新しくてオモシロクもある。

autofix

そんなこともあって、現段階ではカスタムAI開発は好き嫌いが分かれそうだ。
計画通りにやりたい人は発狂しそうだ。せっかちな人はイライラするだろう。ロジカルな人は途方にくれそう。部下より自分でやったほうが早い病の人にも厳しい。

いずれにしても、これは新しいテック体験。ぜひ一度は挑戦してみよう!

☆AIはまだまだこれからオモシロイ!

試行錯誤に時間がかかったり、たまに間違えたりして、まだまだ課題もあるカスタムAI。ただ、もっとも大切なポイントは「これはまだ始まったばかり!」ということだ。これから進化し、精度もあがり、拡張されていく。AIはまだまだ始まったばかり、変化の最先端に立つべく、一緒に挑戦したい人そこのキミ、bravesoftへの連絡を待ってます!

OpenAI API Embeddings+GCPで検索AI系LINEアプリの作り方 #34

GW中に開発した、ボケをおすすめしてくれるLINEアプリをエンジニア向けに解説する。

今回つくったのはボケてをAI検索するLINEアプリ  〜AIボケテンダー「エイジ」〜

LINEで入力された文章(お題)について、意味的に近い回答(ボケ)をピックアップしてレコメンドする仕様だ。この記事ではOpenAIのAPIでこのようなアプリを作りたい人向けの解説をソース付きで書きたいと思う。

AIボケテンダーが動作しているところ

OpenAIのAPIページ

開発の手順は下記の通り

①OpenAIのAPIライセンスを取得
②OpenAI API Embeddingsでボケデータをベクター化
③GCPのCloud Functionsのアカウント発行
④LINE Developersのアカウント発行
⑤CGP上でLINE BOT APIの開発

①、③、④についてはすでにネットにたくさんの記事があるので省略する

②OpenAI API Embeddingsでボケデータをベクター化

OpenAIはたくさんの機能をAPIとして提供している。
今回はたくさんのボケの中から、「入力された文章」と「ボケてのボケの文章」とマッチ度が高い文章検索が求められている。

更には大量の処理済みボケデータをOpenAIのサーバ側にはおかず、自社サーバ(GCP)に「組み込む」ことによって、高速性や低コストでの実現を図っている。
それを実現するOpenAIのAPIがEmbeddingsだ。

EmbeddingsのAPIドキュメント
https://platform.openai.com/docs/guides/embeddings

簡単に説明すると

・事前に文章DBをAPIでベクター処理化してローカルに保存できる
(この際の言語処理力がOpenAIの真価)
・そのデータ(CSV)をGCP上にアップしておく
・検索時に入力された文章をAPIでベクター化し、保存済みベクター化データに対して検索マッチ度が高い順にピックアップして表示させる

ベクター化(数値化)して保存・比較するから数十万のデータに対する検索も一瞬でOK

処理前のボケてデータ【bokeData.csv】

id,text
103973108,を乗せずに海外
103971848,殿、毒見が終わりました。
103971447,バターとチーズ

ボケidとボケの内容のみのシンプルな構成

ベクター処理済みボケてデータ【outputVec.csv】

id,text
103973108,を乗せずに海外,[-0.025080611929297447, -0.012434509582817554,..(省略)]
103971848,殿、毒見が終わりました。,[-0.005088195204734802, -0.018677828833460808, ..(省略)]
103971447,バターとチーズ,[-0.010187446139752865, -0.009211978875100613, ..(省略)]

後半の数値の羅列がいわゆるベクター化された数値データ。↑では2項目ずつだけに省略してるが、その後このような数値が一行あたり1500項目も並ぶ。これらの数値がボケの数文字のテキストをいろいろな方向(ベクトル)に強弱を表現したデータということだ。

データ処理用のソースコード【dataset.py】
pythonでコンソールからこれを実行すれば、同フォルダにあるbokeData.csvを読み込み、OpenAIのAPIに接続しベクター変換処理をして、OutputVec.csvに保存する。

import csv
import openai
import pandas as pd
import tiktoken
import math

from openai.embeddings_utils import get_embedding

# OpenAI APIキーを設定(本番運用時は環境変数にセットすること)
openai.api_key = "ここにOpenAIのAPIキーをいれます"

# 入力データCSVファイル名
input_csv_path = "./bokeData.csv"
#input_csv_path = "./data/sample100.csv"

# Embeddedベクトルデータとして出力するファイル名
output_csv_path = "./outputVec.csv"

# APIにわたすモデル名など
embedding_model = "text-embedding-ada-002"
embedding_encoding = "cl100k_base"
max_tokens = 8000


# 入力CSVファイルを読み込む
df = pd.read_csv(input_csv_path)
df = df[["id", "text"]]
df = df.dropna()
df["combined"] = (
    "id: " + df.id.astype(str).str.strip() + "; text: " + df.text.str.strip()
)
df.head(2)
encoding = tiktoken.get_encoding(embedding_encoding)

# 分割数の設定(1000件ずつ)
chunk_size = 1000
total_rows = len(df)
num_chunks = math.ceil(total_rows / chunk_size)

#APIを叩いてベクトルデータにしてCSVへ保管
for i in range(num_chunks):
    start_index = i * chunk_size
    end_index = start_index + chunk_size
    chunk_df = df.iloc[start_index:end_index]
    chunk_df["embedding"] = chunk_df.combined.apply(lambda x: get_embedding(x, engine=embedding_model))

    # 結果をCSVに付け足して保存
    if i == 0:
        chunk_df.to_csv(output_csv_path, index=False)
    else:
        chunk_df.to_csv(output_csv_path, mode='a', header=False, index=False)

    # 進捗状況を表示
    print(f"書き込みました: {end_index if end_index <= total_rows else total_rows} / {total_rows} 行")

print("埋め込みが保存されました:", output_csv_path)

例えば数万件のデータなら数時間で実行が完了する。
出力されるCSV中身は小数点だらけのベクトルデータ。データ容量はだいぶ増える。例えば元データが10万件、10MBだとしたら、出力データは10万件のままで、容量は50倍の500MB程度の大きさにもになる。

⑤CGP上でLINE BOT APIの開発

ベクトルデータが作成できたら、それをGCPのCloud Storageにアップして、Cloud Functionsから参照できるようにしよう。
そして、Cloud Functionsにデプロイするpythonスクリプトは下記の通り。

LINEへのチャット投稿をトリガーとしてLINEにwebhook登録してあるこのpythonのAPIで、入力された内容とマッチ度の高いボケをcsvからピックアップしてLINEにチャットで回答する。

LINE BOTアプリとしてGCP上で待機するAPI【main.py】


import os
import sys
import openai
import json
import pandas as pd
import numpy as np
from io import BytesIO
from google.cloud import storage #Gクラストレージからcsvを読み取る
from flask import make_response, abort, jsonify
from linebot import (
    LineBotApi, WebhookHandler
)
from linebot.exceptions import (
    InvalidSignatureError
)
from linebot.models import (
    MessageEvent, TextMessage, TextSendMessage
)

#LINE関連
LINE_CHANNEL_SECRET = "ここにLINEのキーをいれます"
LINE_CHANNEL_ACCESS_TOKEN = "ここにLINEのアクセストークンをいれます"

# OpenAI APIキーを設定
openai.api_key = "ここにOpenAIのAPIキーをいれます"
# OpenAI APIのモデル名
embedding_model = "text-embedding-ada-002"

#Gクラバケット名とcsvファイルパス
GC_BUCKET = "bokedata"
GC_CSV    = "outputVec.csv"

#LINEへの接続設定
line_bot_api = LineBotApi(LINE_CHANNEL_ACCESS_TOKEN)
handler = WebhookHandler(LINE_CHANNEL_SECRET)

# Cloudストレージのデータファイルのパス
storage_client = storage.Client()
# Cloud Storageのバケット名とcsvファイル名
bucket = storage_client.get_bucket(GC_BUCKET)
# ベクトル変換済みcsvデータをバイナリから取得
blob = bucket.blob(GC_CSV)
content = blob.download_as_bytes()
df = pd.read_csv(BytesIO(content))

##ローカルcsvでのデバッグ用
# CSVファイルを読み込む
#df = pd.read_csv(datafile_path)
# データファイルのパス
#datafile_path = "../data/outputVec.csv"



# embeddingカラムの文字列を評価し、NumPy配列に変換する
df["embedding"] = df.embedding.apply(eval).apply(np.array)

from openai.embeddings_utils import get_embedding, cosine_similarity

# ボケを検索する関数
def search_boke(df, search_query, n=3, pprint=True):
    # ボケデータからembeddedベクトルを取得する
    boke_embedding = get_embedding(
        search_query,
        engine=embedding_model
    )
    # データフレームの各埋め込みベクトルとのコサイン類似度を計算する
    df["similarity"] = df.embedding.apply(lambda x: cosine_similarity(x, boke_embedding))

    # コサイン類似度の高い順に並べ替え、上位n件の結果を取得する
    results = (
        df.sort_values("similarity", ascending=False)
        .head(n)
        .combined.str.replace("Title: ", "")
        .str.replace("; Content:", ": ")
    )
    # マッチ度の高いボケのidからURLを生成して表示する
    if pprint:
        for r in results:
            print(f"https://bokete.jp/boke/{r.split(';')[0].split(':')[1].strip()}")
    return results

# サーバAPIへのリクエストを受け付けて検索の場合
def main(request):

    signature = request.headers['X-Line-Signature']
    body = request.get_data(as_text=True)

    try:
        handler.handle(body, signature)
    except InvalidSignatureError:
        abort(400)

    return 'OK'

#LINEからのメッセージをうけて実行
@handler.add(MessageEvent, message=TextMessage)
def handle_message(event):

    results = search_boke(df, event.message.text, n=5) #top5を出力

    # ボケのリンク一覧を生成
    links = []

    for r in results:
        boke_id = r.split(";")[0].split(":")[1].strip()
        link = f"https://bokete.jp/boke/{boke_id}"
        links.append(link)

    # 配列を改行で区切られた文字列に変換
    return_text  = "お待たせしました!\n"
    return_text += "\n".join(links)
    return_text += "\n次の注文もどうぞ!"

    #LINEに返信されるメッセージ
    line_bot_api.reply_message(
        event.reply_token,
        TextSendMessage(text=return_text))


#デバッグ用
# サーバAPIへのリクエストを受け付けて検索の場合
"""
def main(request):

    # パラメータqに検索ワードが入力されて、search_queryに代入
    search_query = request.args.get("q", "デフォルト検索ワード")

    results = search_boke(df, search_query, n=5) #top5を出力
    # リンクをHTML形式で作成
    links = []
    for r in results:
        boke_id = r.split(";")[0].split(":")[1].strip()
        link = f"https://bokete.jp/boke/{boke_id}"
        links.append(f'{link}')
    # リンクを一覧にして文字列に変換
    links_html = "
".join(links) # HTMLレスポンスを作成 response = make_response(links_html) response.headers["Content-Type"] = "text/html" return response """ #デバッグ用 #ターミナルから引数を受け取って検索の場合 if __name__ == "__main__": search_query = sys.argv[1] if len(sys.argv) > 1 else "デフォルト検索ワード" results = search_boke(df, search_query, n=5) #top5を出力

コストを抑えたCloud Functionsなため、最初の実行でのメモリへのロードに1分程度時間がかかるが、一度ロードしたら2回目以降の回答は数秒で完了する。
(ある程度コストを掛けてちゃんとしたサーバを借りれば常時数秒でレスポンスできる)

OpenAIのAPIコストは比較的安いと感じた

GW中、何十万回、何十MB分のAPIを叩いたが、APIに送るデータを最小限の文字列にしたのもあってか、1日数百円程度しかかからなかった。
送る文字データが大きくなればなるほど課金が大きくなるらしいが、そもそも安く感じる

開発はますます簡単に!

一つ前の記事にも書いたが、そもそも今回ChatGPTを開発ツールとしても使うことで3倍速を実現している。

また最先端のAIアルゴリズムをAPI経由で活用することが出来るようになった、APIを使用するための労力も課金コストも思ったよりも少ない。

AI開発がこんなに簡単になったのだ。これを機にアイディアを実現すべく挑戦しよう!

あわせて読みたい

①今回開発したLINEアプリで笑いたいならこちら!
今回つくったボケてをAI検索するLINEアプリ  〜AIボケテンダー「エイジ」〜

②今回開発にChatGPTをフル活用することで、「開発が体感3倍速」になった。そこではどんな指示をGPTにしていたのか? その過程を詳細解説!
ChatGPTを使ったら開発が3倍速になった件&GPTへの生々しい指示一覧 #33

ChatGPTを使ったら開発が3倍速になった件&GPTへの生々しい指示一覧 #33

なんと、、1週間程かかると予想していたLINEアプリ開発が2日で終わってしまった。

今回つくったのはボケてをAI検索するLINEアプリ  〜AIボケテンダー「エイジ」〜

話題のChatGPT(GPT-4)、何が便利かは実際に使ってみないとわからない。

いろいろな使い方が発見されているが、その中でも興味深いのは「プログラミングも自動化してくれる」というもの。ある意味自動化の権化であるプログラミングそのものすら自動化してくれるのか!

そこで、いったい何をどこまでやってくれるのか、実際の開発に試してみることにした。

実際にやってみて感じた良いところ

・開発が(体感)三倍速になる
・なんでも知っててなんでも即レスくれる超優秀な後輩感
・ググる回数は実に1/3以下に減る
・細かい文法やライブラリを覚える必要がなくなる
・割と無茶振りしてみると予想以上に応えてくれる
・初めての言語でもたぶん使いこなせる

逆に感じた(現時点での)限界

・複雑なシステム構造などはまだ理解できない
・指示するための一定の技術的知識は必須。
・自信満々に嘘はつくので盲信はできない
 →信じすぎてハマって出戻りしたこともあった

それでは、開発の流れにそって具体的に紹介していく。

まずアイコンの制作も自動化

(アイコン制作:通常1時間→10分に短縮)

今回アイコンの制作もChatGPT+Midjourny(AI自動画像生成)で自動化してしまった。

まずChatGPTにMidjournyへ渡すワード30個を考えさせる→そのワードを元にMidjournyで画像生成。イマイチだったらChatGPTにまた戻ってワードをアップデートを3,4回繰り返すうちにアイコンが完成する

完成したアイコン

実装方針を相談する

ややこしい話だが、ChatGPTに対して、ChatGPTのAPIを使ってどのようなことが実現できるのか、その方針から相談できる。

環境構築のやり方も教えてもらう

(環境構築:通常3時間→30分に短縮)

普段は経営や事業ばかりしていて使っているので、プログラミングできる時間はあまりない。なのでpythonでコーディングするのは実に3年ぶりぐらいだろうか。実行環境構築手順方法すらも忘れていたので手順をサクッと教えてもらう。

Open AIのAPIの叩き方を教えてもらい、サンプルコードを書いてもらう

(サンプルソース動作確認:通常1時間→10分に短縮)

方向性が決まったらより具体的な指示を出し、一旦実行できそうなサンプルコードまで書いてもらい理解を深める。

また、書いてもらったコードの理解をさらに深めるために、csvの内容についても教えてもらうなど。このときにほかから持ってきたソースコードごと貼り付けたうえでその内容を解説してもらうのもOK。

※ただし書いてくれたソースの精度はどうしても不安が残るので、結局は公式サイトのサンプルソースの方をより参考にした
https://github.com/openai/openai-cookbook/blob/main/examples/Semantic_text_search_using_embeddings.ipynb

実際のコーディングとエラーの修正

(実コード一旦実装:通常2時間→30分に短縮)
ファイル名や実装内容など明示して指示をだすことで、実際の本番コードを書いてもらう。

そしてエラーが出たら解説してもらい直してもらう
(エラー調査一回:通常20分→5分に短縮)

エラーの内容もそのまま貼り付けて解説をもらう。さっきまでのソースの内容も一時記憶してくれているので、いちいちこれまでの説明が不要。自分でエラーを追う前にまずChatGPTに貼り付けて回答を待つ→その間に初めて自分でもエラー文を読む。のが効率的。

GCPでAPI化しデプロイ完了

(サーバ構築:通常2時間→30分に短縮)

この間もいろいろとChatGPTにやり方を教えてもらいスピードアップ

Line APIの実コード実装

(実コード実装:通常2時間→30分に短縮)

Line APIを使った開発は以前コロナボットでもやっていたので概要はわかっていたが、ソースの書き方までは忘れていた。今回は過去に自分で書いたソースを見直すこともなくChatGPTにソースを実装してもらいコピペでサクッと行けた。

ソースコードの修正

(ソース書き換え:通常20分→5分)

開発を進めていると、データの順番を入れ替えたり、入力元を変えたり、出力形式を少し変えたりと何かとコード変更が発生する。そんな修正も指示すればすぐやってくれる

これがChatGPTへの生々しい指示一覧!

とくに編集も加えず、今回ChatGPTに指示した内容をそのまま一覧にする。(たぶん実際はこの倍ぐらいある。類似してたり記録漏れなどもあったので)

中身は深く考えず、こんなことを指示したんだなぁぐらいでざっと見て欲しい。(というか僕も覚えていないものが多い笑)

肌感では、期待通りの合格回答が得られた率は8割以上。上出来といえる。

もうググったりいろんな人に質問したりと時間がかかってた時代には戻れない。プログラミング開発速度はグッと上がったということを実感。

カテゴリ:質問&TIPS編

chatgpt apiでデータ学習させて制度の高い検索ボットをつくることはできる?

でも、関連性の高い検索を実現するにはembeddingのほうが良くないですか?

この方法が説明されているAPIリファレンスのURLを教えて

openaiの埋め込みのapiの料金を教えて

この場合の1行あたりの平均トークン数はいくつですか?

データセットのインプットはテキストデータで、レスポンスは関連度の高い順のurlリストとなるような検索に使いたいけどどうやる?

このときのget_embeddingsの実行は1万件のデータならどれぐらい時間やAPI使用量がかかりますか? 2021年9月時点の情報で良いです

openapi でembeddingのrecommendをやりたいです。csvの読み込みやapi呼び出しのやり方を解説して

cloud functionsでcloud strageから読んだデータをメモリに保管しておいて、毎回のアクセスで読み込まないようにキャッシュする方法はありますか?グローバル変数などで

macでutf-8のcsvを編集する方法は?

シェルのコマンドを実行するときに実行にかかった秒数を表示するには? python –versionコマンドの例で

pythonで一行だけでなく範囲を指定してコメントアウトするには?

line messaging apiのwebhookをつかって、cloud functionでラインでメッセージが送られてきたらhello worldと返すようなapiの開発の流れを詳しく教えて

カテゴリ:環境構築編

python 実行環境を作るには? macです

Default output format [None]:にはなにをいれる?

YOUR_ROLE_ARNはどこを調べたら出てくる?

こんなエラーが出たけどうどうしたらインストールできる? You have 2 outdated formulae installed.

こうなりました  % brew upgrade openssl
Error: openssl not installed

カテゴリ:実装編

このソースコードの実行が早くなるように書き換えて

このソースコードでopenaiのapiを実際に叩いているのはどの関数ですか?

このときのyour_csv_file.csvファイルの内容はどんなものになりますか?例を示してください

このサンプルコードを、実行できる一つのテキストに統合して

このソースコードに日本語のわかりやすいコメントを付けて(後略)

さっきのサンプルコードについて、csvの内容をid,textだけのシンプルな2列にした内容で書き換えてみて

これはcloud functionsでrequestを受け取るところです。パラメータqに検索ワードが入力されて、search_queryに代入するように書き換えて # def main(request):(後略)

このソースコードについてsearch_queryをコンソールからの引数を当てはめるのと、該当した行のidについて”https://bokete.jp/“を冒頭につけてprint出力するように書き換えて(後略)

このソースの、index_col,dropna,headの意味をそれぞれ教えて # imports(後略)

このソースコードについて、csvから1000件ずつ読み込んで逐次実行し、1000件ずつ結果をcsvに付け足して保存する。いま何件まで完了したかをprint表示するようにソースコードを書き換えて(後略)

下記のコードで、 cloud strageからの最初の実行でcsvの読み込みが遅いので、事前に常時メモリにロードしておき初回実行のレスポンスを早めることはできますか? import os(後略)

この関数について、return_textに、配列resultsから生成したURL一覧が入るように書き換えて 

このtiketokenってなに? # imports
import pandas as pd
import tiktoken(後略)

このソースのprint出力から、 id: という出力をカットして本当のid番号だけが出力されるようにしたい

このときに、テキストだけじゃなくidもprintしたい

さっきのコードだけど、丁寧なコメントも付けてみて

ソースコードを日本語に直して、入力ファイル名は冒頭の変数宣言にして、 ./data/sample100.csvにして

embedding のapplyなどをつかって、csvファイルを読み取りベクトルデータに変換して別のcsvファイルに保存するコードを書いて

your_query_textについて、pythonでコマンド実行時にパラメータで渡す形にこのコードを修正して

カテゴリ:エラー解決編

このエラーはどういう意味ですか? /Users/(中略)/./dataset.py:45: SettingWithCopyWarning:

このソースの問題点を教えて import openai(後略)

cloud functionsでコンソールからのデプロイに失敗したときのデプロイエラーを調べるログはどこから見れる?

この文法エラーを直して

このソースコードに問題があるところを指摘して

このエラーの直し方 % python ./dataset.py
Traceback (most recent call last):(後略)

終わります。みんなでやってみよう!

今回LINEアプリ開発を3倍速で作ったときのChatGPTへの指示を載せてみました。
GhatGPTを使うことで一気に開発スピードがアップするイメージがもてただろうか?

誰でも簡単にサクッとプログラミングが出来る時代。
素晴らしい時代。

開発が簡単になる先に大事なことは課題発見力、アイディアや行動力になってきます。
臆することなく突き進んでいきましょう。

AIの時代はまだまだ始まったばかり!

あわせて読みたい

①今回開発したLINEアプリで笑いたいならこちら!
今回つくったボケてをAI検索するLINEアプリ  〜AIボケテンダー「エイジ」〜

②今回のOpenAI APIを使ったLINE BOT開発について、エンジニア向けに詳しい流れやソースについても解説!
OpenAI API Embeddings+GCPで検索AI系LINEアプリの作り方 #34

OpenAIの技術であなたのお題に「ボケて」を返すLINE AIアプリをつくった #32

最近のChatGPTの勢いはスゴイ。
そしてOpenAI社はAPIを提供してるので、アイディア次第で誰でもAIアプリを作れる

じゃあ早速やってみよう!ということで

AIボケテンダー「エイジ」 

というLINEアプリをGW中に開発したのでここに公開します☆
(後半ではエンジニア向けに作り方も解説)

AIボケテンダーの遊び方

ぱっと思いついたことをお題としてLINEに書き込むと、「1億ボケを誇るボケて」の中からお題に合った笑えるボケを返してくれる。

たとえばこんな感じ(クリックで拡大)

こんなときに使ってみよう

・とにかく笑いたい!
・思ったことを書き込んで反応が欲しい
・プレゼンで使うボケを探したい
・友達に気の利いた「ボケ」を送りつけたい

早速遊んでみよう!

利用方法は超カンタン、AIボケテンダーとLINE友達になるだけです
友達になって早速お題を話しかけてみよう

※安価なサーバにつき初回の応答に1分程度かかります。続いて2回以降の応答はスムーズです。好評ならつよつよサーバにアップグレードします☆
※LINEの無料枠を使ってるので一定数を超過すると応答しなくなったりします。そんなときは@bravingのtwitterまでお知らせください

より良いお笑いライフを!

ご意見ご要望はtwitter:@bravingまで。
おもしろかったらSNSシェアしてね!




開発者向け解説コーナー

①今回開発にChatGPTをフル活用することで、「開発が体感3倍速」になった。そこではどんな指示をGPTにしていたのか? その過程を詳細解説!
ChatGPTを使ったら開発が3倍速になった件&GPTへの生々しい指示一覧 #33

②今回のOpenAI APIを使ったLINE BOT開発について、エンジニア向けに詳しい流れやソースについても解説!
OpenAI API Embeddings+GCPで検索AI系LINEアプリの作り方 #34

【braver列伝Ep1】社員1号はアル中エンジニア!?「ナベちゃん(仮称)」の記憶 #31

広報チームより「全員参加!毎日ブログ投稿企画」の執筆依頼が来た。
(今イベ博DAYSの準備でメッチャ忙しいけどね!)

bravesoftのカルチャーを語ってほしいとのこと。

ベンチャーを創業し成長させる道程には、ちょっと変わった体験談がいくつもある。
それをシェアするのも面白いかもということで、braver列伝を書くことにした。

bravesoftの歴史とは、社会の路地裏で狂い咲く雑草キャラクター達の歴史である。

<<< 社員1号はアル中エンジニア!?「ナベちゃん(仮称)」の記憶 >>>

ナベちゃんはウツだった。そしてアル中だった。

性格は極めて温厚。お酒を飲むと少しだけ攻撃的になる。
そして次の日に会社に来ることはあまり期待できない。

僕だけに理由を話した。幼少期にひどいイジメにあったとか。
ストレスフルなことが起きるとついお酒に手を出しちゃうのだ。

出会ったのは大学生の頃。同じ情報科学部でプログラムもソコソコできた。
大学では特に目立たずフラフラと日常をやり過ごしていた。

学業に身も入らず、なんとなく留年していた。

そんなナベちゃんが一念発起した。
僕らが卒業後すぐに起業した「(有)ジオマックス」に応募してきたのだ。

ナベちゃんは僕に半端なくナツイた。ほぼ毎日を一緒に過ごし、
プログラミングを教えてあげたり、Webの未来を語ったりした。
完全なる師弟関係。メキメキ技術力も伸びてきた。

「隊長(って呼ばれてた)の技術力は別次元す!地獄の果てまでついていきやす!」

お調子者でピュアなキャラクター。周りからもイジられたり人気者だった。

ジオマックス。それは新卒役員3名と、留年組バイト達で構成された弱小ベンチャー。
キツイ案件でも積極受注。トラブルだらけの毎日を過ごしていた。
でも若かったし仲間だったから、何が起きてもサイコーだった。
(そんな気分は今でも続いている笑)

ナベちゃんはちょくちょく失踪した。その度に住んでいる駒込付近を捜索した。
「小綺麗なスナックがあったから写真送ります」みたいなメールを送ると返事がきた。
公園で一人飲んでたり、住宅街でしなびてたり。手のかかる弟子だった。
ナベちゃんはいつでもヘコたれながら、なんとか生きようとしていた。

・・・・・

起業から1年経過した頃。価値観の違いから僕はジオマックスを去ることにした。
(この話はまた別の機会に)

そして、僕なりの理想を追求するためにbravesoftを起ち上げることにしたのだ。
当然のように、弟子のなべちゃんは僕についてきた。
大学もやめてフルコミットするとのこと。とにかくやる気満々の1号社員である。

「お酒をやめます」

ナベちゃんは宣言した。

大学留年組の友人も続々と参加してくれて、気づけば6人ぐらいでのスタートとなった。
戦争のような日々が始まった。激しいけど愉快な日々を走り出したのだった。

・・・・・

それから1年ぐらい経過した頃。一転してbravesoftには不協和音が流れていた。

資金繰りも厳しく炎上続きでピリピリしてる社長。少しずつ心が離れていく社員。
今となってはよく聞く話だけど、まだ20代前半で知識も無く視野も狭かった。

初年度の売上は3000万を超え新入社員も採用し、念願だった1000万円の融資がおりた。手応えと責任に身震いした。でも、僕の気づかないうちに、組織は限界を迎えていたのだ。

「こんな会社やめてやる」

ある日、全社員にナベちゃんからこんなメールが届いた。

禁酒は1年と持たなかった。彼は泥酔し昏睡し発狂していた。

ナベちゃんを責めることは出来ない。

彼はその不協和音が一番強く聞こえてしまうセンサーを搭載しているのだ。
いずれ破裂するはずだった風船の、一番薄い部分がナベちゃんだったに過ぎない。

急いで慶応大学三田キャンパス横にあるナベちゃんのマンションに向かう。
よれよれで酒臭いナベちゃんが出てくる。目の焦点は合っていない。

それからナベちゃんの母親、そして創業メンバーともいろいろと話し合った。お互いに悪いところがあった。仕切り直そう。あの頃に戻ってやりなおそうよと。

「もう絶対にお酒はやめます」

ナベちゃんは再び宣言した。

とにもかくにも仕切り直すことにした。話せばわかる。もともと皆友達だ。
ナベちゃんは絶対にお酒をやめると言っている。我々も改めて初心に帰ろうよと。
全員でそんな話をして、再スタートを切ることにした。

それでも。。目の前の案件は炎上続き。僕は営業で外出続き。資金繰りは緊急事態続き。たまに会社に戻ると週末のパチンコや夜遊びの話で盛り上がっている。意識の差は埋められそうにない。急に連絡が取れなくなる新入社員がいる。朝もちゃんと集まらない。

やっぱり根本的に何かが難しかった。
僕も含めそれぞれに子供であり、学生サークルのノリを超えられなかったのだ。

「こんな会社やめてやる」

それが終わりの合図だった。

ナベちゃんはまたすぐにアル中を再開した。

僕にはこの状況を改善できなかった。誰もがこのまま続けることは難しいと感じた。

それからの詳しい経緯はあまり覚えていない。結局はナベちゃんも創業メンバーの友達も全員が辞めることになった。僕は窮地に追い込まれた。ドキドキしながらようやく借りることができた初融資の1000万円もあっという間に失った。そこから3年は本当に生きるか死ぬかの毎日だった。

今思えば若いうちに大きな失敗を経験してよかったと思う。卒業してすぐ起業したジオマックスを1年で辞める失態、そこから起業して1年後にまた大きく失敗したのだ。2回の大きな失敗は自信過剰な僕にこそ問題があるのだと思い知らされた。他者について真摯に考える思考を得た。ジョブズじゃないけど今思えば最良の出来事だったと思う。

誰もいない路地裏となった2年目のbravesoft。それでも救いの手もあった。その直後に入社したメンバーは今でもチーフをやってたり子会社社長になっていたりする。あれから15年。10億以上の資金調達をしたり、1億DLを超える実績を創ったり、グッドデザイン賞や経産省大臣賞をもらったり、いろいろなトラックレコードを積み上げてきた。

あの頃には考えられなかったほど大きな仕事をしている。なにもかもが変わった。
もはやあの頃の創業メンバー達の面影は、何ひとつ残されてはいない。

・・・と、思うでしょう?

実は今でもbravesoftに1つだけ残っているナベちゃんの足跡がある。
これは今まで誰にも言ってこなかったことだ。

あれは創業当時のこと・・

「隊長〜!遂にうちもネット開通しやした!! Wifiパスワード何にします?」

「うーんそんなのなんでもいいよ適当に決めといて」

「わかりやした。じゃあ”bravesoftnabe”ってしときますヨ!うふふ」

「おまえエゴ丸出しやな!笑」

・・・その後10年に渡り、このパスワードは社内Wifiに使われ続けた。そして、今でもこの文字の面影はなんとなく残っている。ナベちゃんのことは誰にも話してないから、これは奇跡と言っていい。

・・・・・

これから先もこの足跡が残り続けるかどうか、それは僕にもわからない。
あの頃のメンバーが今どこにいて、何をしているのか、それもわからない。

VUCAの時代。あらゆるものが変わる時代。
すべてが移り変わる中では逆に、ずっと変わらないものの価値もあるはずだ。

あのときの情景、挑戦のワクワク感、純粋ゆえに衝突し、最後は笑い合う。丸裸の関係。まだ何も持ってはいない。あるのは体力、希望、根拠なき自信。ほんの少しのお金と勇気。ひたむきに生きる以外になにもなかったあの頃。

多くの人にとってはそれは若い時代の昔話で、セピア色の想い出であろう。
青春というフォルダにしまっておいて、たまに読み返したりするアルバムだ。

でも、

僕は今日でも、そんな毎日を、あの時と変わることなく戦い続けている。
あれから15年間経過したけど、変わらぬ希望の火を燃やし続けている。

それこそが、僕の最高のトラックレコードだ。

——-

あの頃の自分に誇れるように。ナベちゃんの自慢話にもなれるように。
これからも大きく挑戦していこうと思います。

ということでbraverの皆さん、一緒に頑張ろう!!!!!

——-

追伸:
ナベちゃん、そろそろ連絡待ってるヨ!

受付アプリ(iPad)をSwiftUI+Google Calendar API+ Slack APIで自作する方法(ソース付) #30

社長のやるべき仕事は幅広い。

最近、ついに我が社もNTT電話が断舎離されることになった。

コスト削減は良いことながら、社内からこんな声が・・

「社長!受付電話が無くなるから流行りの受付アプリ(100万円ぐらい)導入します☆」

ちょっと待った

最強のものづくり集団を目指すbravesoftがその程度のアプリを自作しないでどうするのだ!

・・・かくして、社長自らGWに「率先自作」することになったのだ。

GWに5日ぐらいかけてSwiftUIの学習がてら開発したのがこれ(右側はslack全社ch)。

仕様はシンプルで、

①Googleカレンダーから直近のMTG予定者を取得して表示
②最短2タップで受付登録が完了
③全社slackにお客様名や会議名を投稿

直近の打ち合わせ予定者のみ上に表示するので大体はお目当ての担当が一発で見つかる。

実際社内リリースしてかれこれ1ヶ月ぐらい普通に使われている。たのしき。

そこで、本ブログではものづくり同志のためにこのアプリの開発手順を解説しよう。

今回、SwiftでGoogle Calendar APIに接続した訳だが、まだまだレアケースらしく参考にできる記事は(英語圏も含めて)かなり少なかった。そこでざっと設定手順をまとめ、最後にはソースコードもそのまま記載する。誰かの参考になれば幸い。

ちなみに当方の動作環境はこんな感じだ

(画像はクリックで拡大できる)

①まずはXCodeでSwiftUI実行環境を準備

まずはXCodeをインストール。SwiftUIはXCode11以降なら標準でついてる。(実は当初はFlutterで開発しようと思っていたが、最新のMac(M1チップ)でAndroid Studioがちゃんと動かず、苦労しそうだったので急遽Apple純正のSwiftUIに変更。マルチプラットフォームの開発言語はちょくちょくこういう問題起きるので注意。

SwiftUIはこれまでのStoryBoardとうってかわって分かりやすい。UIを全てコードで記述→HTML感覚で画面を見ながらいじれるので簡単。

さらにライブラリインストール環境のCocoaPodsをインストール。これもM1チップの都合で、Rosettaモードなるものでターミナルを起動する必要あり。

CocoaPodsインストール完了。必要なライブラリもコードで記述。Google APIやHTTPリクエスト関連ライブラリのインストールに重用した。(コード全文は文末に記載)

②Google認証(OAuth)と連携する

全社員の予定状況を知るためには、会社のGoogleアカウントでGooleログイン状態になることが必要になる。これはアプリからGoogleに遷移してGoogleログインし、またアプリに戻ってくるOAuthの流れを実装することで可能になる。(実はもともとは特定個人アカウントが不要な「サービスアカウント方式」で接続しようしたが、手続きが分かりにくく情報も少ないためOAuthを選択した。短期間の目的達成のためこういう判断が重要)

まずはOAuth画面の設定だ。あらかじめ使用する情報(スコープ)を明示しておくと、iPadアプリからGoogleログインする際にどの情報にアクセスされるか明示される。その上で、Googleログインした状態でiPadアプリに戻ってくるという流れ。

次にクライアントIDやURL SchemesをGoogleから取得。これらの識別子をソースコードやplistファイルに指定すれば、OAuthの設定は完了だ。

一つハマったバグあり。AppAuthを使用してログインするところで謎のエラーが発生。

接続エラーです:Error Domain=org.openid.appauth.general Code=-3 "(null)" 
UserInfo={NSUnderlyingError=0x600000a95230 
{Error Domain=com.apple.AuthenticationServices.WebAuthenticationSession Code=2 
"Cannot start ASWebAuthenticationSession without providing presentation context. 
Set presentationContextProvider before calling -start." 
UserInfo={NSDebugDescription=Cannot start ASWebAuthenticationSession 
without providing presentation context. Set presentationContextProvider before calling -start.}}}

調べたところ、最新のiOS14と、AppAuth1.1.0は相性が悪いらしい。そこでインストールバージョンをAppAuth0.9.5にダウングレードしたところ解消。

③Google Calendar APIと連携する

OAuth連携ができると、Google APIが叩けるようになる。今回利用したのは、


Google Directory API..会議室、社員、社員サムネ写真の取得
Google Calendar API…カレンダーイベント情報の取得

Calendar APIの設定は比較的簡単に完了する。その次のDirectory APIで会議室や社員、社員のサムネイル画像などを取得するコーディングは大変だった。英語圏も含めてネットに情報が少ないので、Googleライブラリの原典を読み込んだりしてようやく実装。詳しくは後述のソースコードを参考に。

④Slack APIと連携する

さあ、会議予定がある人を画面に並べて、来客情報を入力させるUIが出来たら、最後はSlackで投稿するための設定。

まずはApp(bot的なもの)をslackに追加、そしてWebhook(=API)を追加する。Webhookはslackチャンネルごとに設定する、投稿用の専用URL。

Webhookをテスト、本番それぞれ作成して、Appの認証情報を取得。割と簡単。

完了!ソースコードを全公開☆

以上で設定は完了! SwiftUIベースでUIを動かしていけば受付アプリが完成する。今回、HTMLかのようにUIを宣言ベースで作れるSwiftUIの良さを理解。またGoogleやSlack等の業務系のAPI連携が充実してきていることで、業務DXは格段に推進しやすい環境が整いつつあると感じた。業務をハックする感覚で、思いついた人がどんどんDXしていけば良いのだ。

下記にソースコードを公開する。ソースの中でも詰まったポイントや工夫した点がいろいろあるが、時間の都合で解説しきれないので、とにかくソースを強引に貼り付けておく。詳しく知りたいエンジニア諸氏はぜひ当社に入社しちゃってください 🙂

Podfile(Swiftライブラリ用)

まずはSwiftライブラリインストールに必要な設定ファイルの内容

# Uncomment the next line to define a global platform for your project
# platform :ios, '9.0'

target 'ReceptBot' do
  # Comment the next line if you don't want to use dynamic frameworks
  use_frameworks!

  # Pods for ReceptBot
  pod 'AppAuth','0.95.1'
  pod 'GTMAppAuth'
  pod 'GoogleAPIClientForREST/Calendar'
  pod 'GoogleAPIClientForREST/Directory'
  pod 'Alamofire'

  target 'ReceptBotTests' do
    inherit! :search_paths
    # Pods for testing
  end

  target 'ReceptBotUITests' do
    # Pods for testing
  end

end

ReceptBotApp.swift(起動処理)

次にSwiftUIの起動処理。これはもうテンプレ通り。

//
//  ReceptBotApp.swift
//  ReceptBot
//  braver受付アプリの起動処理。viewを呼び出すだけ
//  Created by esga on 2021/04/29.
//
import SwiftUI

@main
struct ReceptBotApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

ContentView.swift(画面処理)

画面まわりのメイン処理。SwiftUIによって「社員情報が取得できたら、画面を自動再描画」してくれるのでコード記述がシンプル。

//
//  ContentView.swift
//  ReceptBot
// 受付アプリのメインビュー。起動後まず会議室でMTG予定者のリストを表示
//  Created by esga on 2021/04/29.
//
import SwiftUI
import Alamofire

/*-------------全体で使うグローバル変数-------------*/

//テストchannel
var SLACK_WEBHOOK = "https://hooks.slack.com/services/TQRK*****1sss"
//本番channel
//var SLACK_WEBHOOK = "https://hooks.slack.com/services/TQRK*****tYz"

/*--------以下はBraverByGoogle内より参照される------*/

//var DEBUG_TODAY = "2021-5-10T10:00"    //今日の日付 本番では未入力。日付が入力されたらテストモード
var DEBUG_TODAY:String!   //本番はこちらを適用

//Google APIの認証関連情報
let CLIENT_ID = "8949**********h31r5.apps.googleusercontent.com"
let URL_SCHEME = "com.googleusercontent.apps.8949**********31r5"
let SCOPES         = ["https://www.googleapis.com/auth/calendar.readonly",
                      "https://www.googleapis.com/auth/calendar.events.readonly",
                      "https://www.googleapis.com/auth/admin.directory.user.readonly",
                      "https://www.googleapis.com/auth/admin.directory.resource.calendar.readonly"]
/*----------------------------------------------*/

struct ContentView: View {
    
    @State private var showInputForm: Bool = false        //モーダル入力フォームの表示指示
    @ObservedObject var braveByGoogle = BraverByGoogle()  //MTG予定があるbraversのリストを保持
    @ObservedObject var selectedBraver = SelectedBraver() //選択されたBraverの情報を入力フォームに引き渡す
    
    let timer = Timer.publish(every: 300, on: .main, in: .common).autoconnect()  //自動リロードタイマーの設定.5分おき
    
    var body: some View {
        
        ZStack(){
            AnimatedBackground().blur(radius:20)  //システム稼働中であることを示すため背景色グラデを揺らす
            
            HStack(alignment: .top){
                VStack(){
                    HStack(alignment: .center) { //画面上部の案内メッセージ
                        VStack(alignment: .leading){
                            
                            Text("ご来社ありがとうございます!")
                                .font(.title)
                                .fontWeight(.semibold)
                                .foregroundColor(Color.white)
                            
                            Text("目的の担当者を選択するか、「その他の担当者」を押してください")
                                .font(.footnote)
                                .fontWeight(.medium)
                                .foregroundColor(Color.white)
                                .lineLimit(0)
                                .padding(.top, 1.0)
                        }
                        Spacer()
                        Button(action: {//その他の担当が押されたら
                            
                            selectedBraver.name = ""
                            selectedBraver.mail = ""
                            selectedBraver.location = ""
                            showInputForm = true;  //モーダルを表示する
                            
                        }){
                            //目的のbraverが表示されてなかったりその他の要件のため
                            Text("その他の担当者>")
                                .font(.system(.title3, design: .rounded))
                                .fontWeight(.semibold)
                                .foregroundColor(Color.white)
                                .padding(.vertical,20)
                                .padding(.horizontal,30)
                        }
                        .overlay(
                            RoundedRectangle(cornerRadius: 10)
                                .stroke(Color.white, lineWidth: 1) //ボタンの枠線
                        )
                        .sheet(isPresented: self.$showInputForm) { //ボタンが押されたらモーダルで入力フォーム表示
                            //モーダル遷移で表示するビュー。その他の担当
                            HStack {
                                InputFormView(selectedBraver:self.selectedBraver, clientName:"", clientCompany:"" ,note:"")
                            }
                        }
                    }
                    .padding(.vertical, 20.0)
                    .padding(.horizontal, 10.0)
                    .background(Color.blue)
                    
                    ScrollView{
                        generateButtons() //ボタン(braver)の配置は座標が動的なため別途定義
                    }
                }
            }.alert(isPresented: $braveByGoogle.networkError) {  // Slack送信したらアラートを表示する
                Alert(title: Text("ネットワークエラーです"),
                      message: Text("ネットワークに接続されていないか、サーバ側でエラーが発生しています。確認のうえ再度お試しください"),
                      dismissButton: .default(Text("了解"),
                                              action: {
                                              }))
            }
            
        }
        .onAppear(){ //画面出現時に実行するメイン処理。Google APIへアクセスし、braversリストを取得
            //OAuthでGoogleに接続して予定参加者を取得
            braveByGoogle.setBraversByGoogle(viewController:UIHostingController(rootView: ContentView()))
        }
        .onReceive(timer, perform: { time in  //定期タイマーでメイン処理を再実行し自動リロードする
            braveByGoogle.setBraversByGoogle(viewController:UIHostingController(rootView: ContentView()))
        })
    }
    
    //braversが表示されるボタンを3列ずつ動的に表示。
    private func generateButtons() -> some View {
        
        var width = CGFloat.zero
        var height = CGFloat.zero
        var i = 0
        
        return ZStack(alignment: .topLeading) {
            ForEach(braveByGoogle.braverList, id: \.id) { braver in //取得済みのbraverを順に表示

                buttonItem(braver:braver)
                    .padding(.vertical, 10)
                    .padding(.horizontal, 15)
                    .alignmentGuide(.leading, computeValue: { d in
                        //print("name:\(braver.name) i:\(i) dw.:\(d.width) dh.:\(d.height)")
                        if i%3 == 0  { //4人めで左下へ座標を移動させる
                            width = 0
                            height -= d.height
                        }
                        let result = width
                        if i >= braveByGoogle.braverList.count { //全員分終了で座標をもとに戻しておく
                            width = 0
                        } else {
                            width -= d.width //ボタンのサイズ分右にずらす
                        }
                        i += 1
                        return result
                    })
                    .alignmentGuide(.top, computeValue: { d in
                        let result = height
                        if i >= braveByGoogle.braverList.count {
                            height = 0
                            i = 0
                        }
                        return result
                    })
                }
        }.frame(maxWidth: .infinity) //横幅いっぱいまで画面を使用する
    }
    //ボタン1つ1つの設定
    func buttonItem(braver:Braver) -> some View {
        Button(action: {
            print("ボタンが押されました\(braver.name)")
            selectedBraver.name = braver.name
            selectedBraver.mail = braver.mail
            selectedBraver.location = braver.location
            showInputForm = true //モーダルで入力フォームを表示
        }){
            HStack(){
                Text("")  //バランス調整のためダミーのTextを挿入
                Image(uiImage:braver.image!) //braverのサムネを表示
                    .resizable()
                    .frame(width: 50, height: 50)
                    .clipShape(Circle())
                Text(braver.name) //braverの名前を表示
                    .foregroundColor(Color.blue)
                    .font(.system(.title3, design: .rounded))
                    .fontWeight(.semibold)
                    .frame(width:130, height:70, alignment: .leading)
            }
        }
        .background(Color.white)
        .cornerRadius(10)
        .overlay(
            RoundedRectangle(cornerRadius: 10)
                .stroke(Color.blue, lineWidth: 1) //ボタンの枠線
        )
        .compositingGroup()        // shodow用にグループ化
        .shadow(color: .gray.opacity(0.6), radius: 5, x: 7, y: 7)
        .sheet(isPresented: self.$showInputForm) { //ボタンが押されたらモーダルで入力フォーム表示
            //モーダル遷移した後に表示するビュー
            HStack {
                InputFormView(selectedBraver:self.selectedBraver)
            }
        }
    }
}

//受付用の入力フォーム。入力内容をslackへ通知
struct InputFormView : View {
    
    @Environment(\.presentationMode) var presentationMode //閉じるボタン用
    @ObservedObject var selectedBraver: SelectedBraver //選択されたBraverの情報受付用
    @State var clientName = ""           //お客様の氏名保存用
    @State var clientCompany = ""        //お客様の社名保存用
    @State var note = ""                  //備考保存用
    @State private var showAlert = false  // 呼び出し結果アラート表示用
    @State private var sendError = false  // ネットワーク接続結果表示
    @State private var SUBJECT = ""    // ダイアログ表示タイトル。成功か失敗かで変わる
    @State private var BODY = ""       // ダイアログ表示文

    var body: some View {
        VStack(alignment: .leading){
            Spacer()
            HStack{
                Text("お客様情報を入力して担当者を呼び出してください")
                    .font(.title3)
                    .fontWeight(.semibold)
                    .foregroundColor(Color.white)
                    .padding(.vertical,10)
                    .padding(.horizontal,15)
                Spacer()
                Button(action: {  //閉じるボタン
                    self.presentationMode.wrappedValue.dismiss() //このフォームを閉じる
                }){
                    Text("閉じる")
                        .font(.subheadline)
                        .foregroundColor(Color.white)
                        .padding(.vertical,10)
                        .padding(.horizontal,15)
                }
            }
            Form {
                Section(header: Text(" ")){}
                Section(header: Text("お客様の氏名(必須)").font(.headline)){
                    TextField("カンタンでOK", text:$clientName)
                }
                Section(header: Text("お客様の会社名(任意)").font(.headline)){
                    TextField("カンタンでOK", text:$clientCompany)
                }
                Section(header: Text("bravesoftの担当者(任意)").font(.headline)){
                    TextField("カンタンでOK", text: $selectedBraver.name)
                }
                Section(header: Text("備考や目的など、必要に応じて入力ください(任意)").font(.headline)){
                    TextEditor(text:$note)
                }
            }
            //担当者を呼ぶボタン
            Button(action: {
                sendError = false  //エラーだった場合あらためてリセット
                //登録された内容を元にSlackへ送信
                postToSlack(braverName:selectedBraver.name, location:selectedBraver.location,clientName:clientName, clientCompany:clientCompany, note:note )
            }) {
                Text("担当者を呼ぶ(Slackで通知します)")
                    .font(.largeTitle)
                    .fontWeight(.semibold)
                    .foregroundColor(Color.white)
                    .frame(maxWidth: .infinity, maxHeight: 100, alignment: .center)
            }
            .background(Color.blue)
            .alert(isPresented: $showAlert) {  // Slack送信後のダイアログ表示
                Alert(title: Text(SUBJECT),
                      message: Text(BODY),
                      dismissButton: .default(Text("了解"),
                                              action: {
                                                self.presentationMode.wrappedValue.dismiss() //了解が押されたらフォームを閉じる
                                              }))
            }
            Spacer()
        }
        .background(Color.blue)
    }
    
    // Slackへ受付情報を投稿する
    func postToSlack(braverName:String, location:String, clientName: String, clientCompany: String, note: String) {
        
        let headers: HTTPHeaders = [
            "Content-Type": "application/json"
        ]
        let parameters: Parameters = [
            "attachments": [
                [
                    "color": "#0066FF",
                    "text": "\nお客様が「\(braverName)」さんを呼出してます!「\(braverName)」さんが気付いてない場合、同部署や近所のbraverが教えてあげる or 変わりに会議室に案内ください! 絶対に1分以上待たせないで!!",
                    "fields": [
                        [
                            "title": "bravesoftの担当者",
                            "value": braverName,
                        ],
                        [
                            "title": "お客様社名",
                            "value": clientCompany,
                            "short": true
                        ],                   [
                            "title": "お客様氏名",
                            "value": clientName,
                            "short": true
                        ],
                        [
                            "title": "会議名と場所(あくまで予想)",
                            "value": location,
                        ],
                        [
                            "title": "備考・目的",
                            "value": note,
                        ]
                    ]
                ]
            ]
        ]
        
        AF.request(SLACK_WEBHOOK,
                   method: .post,
                   parameters: parameters,
                   encoding: JSONEncoding.default,
                   headers: headers).responseString { response in

                    switch(response.result) {
                    case .success(let value):
                        sendError = false
                        SUBJECT = "担当者を呼び出しました!"
                        BODY = "1分経過しても担当者が現れない場合は、申し訳ございませんが再度お呼び出し頂くか、お近くのスタッフまでお声がけください。"
                        print("Slack接続成功:\(value)")
                    case .failure(let error):
                        sendError = true   //送信失敗アラート表示
                        SUBJECT = "ネットワークエラーです"
                        BODY = "ネットワーク接続か、サーバに問題があります。設定を確認のうえ、再度お試しください"
                        print("Slack接続エラー:\(error)")
                    }
                    showAlert = true   //アラートはどちらにせよ表示表示
                   }
    }
}

//選択されたbraverの情報を保持し共有する
final class SelectedBraver: ObservableObject {
    @Published var name = ""
    @Published var mail = ""
    @Published var location = ""
}



//背景画像をグラデーションで動かすためのView
struct AnimatedBackground: View{
    @State var start = UnitPoint(x:0, y:-2)
    @State var end = UnitPoint(x:4, y:0)
    
    let timer = Timer.publish(every:1, on: .main, in: .default).autoconnect()
    let colors = [Color.white,
                  Color(red: 190/255, green: 220/255, blue: 255/255), //水色系の色でグラデをかける
                  Color(red:  90/255, green: 180/255, blue: 255/255),
                  Color(red: 150/255, green: 200/255, blue: 255/255)
    ]
    var body: some View{
        LinearGradient(gradient: Gradient(colors: colors), startPoint: start, endPoint: end)
            .animation(Animation.easeInOut(duration:3)
                        .repeatForever()
            )
            .edgesIgnoringSafeArea(.bottom)  //これしないとキーボード出現時に下部が無効化されてしまう
            .onReceive(timer, perform: { _ in
                start = UnitPoint(x:4, y:0)
                end   = UnitPoint(x:0, y:2)
                start = UnitPoint(x:-4, y:20)
                start = UnitPoint(x:4, y:0)
            })
    }
}
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        Group {
            ContentView()
                .padding(9.0)
        }
    }
}

BraverByGoogle.swift(Google通信処理)

Google APIに接続して必要データを取得してくる処理。

//
//  BraverByGoogle.swift
//  ReceptBot
//  Google APIに接続してOAuth認証・Directoryよりbraver取得・Calendar APIより予定を取得
// 本日会議室で予定が入っているbraverのリストを維持する
//  Created by esga on 2021/05/01.
//

import Foundation
import SwiftUI
import GTMAppAuth
import AppAuth
import GoogleAPIClientForREST

//会議室(リソース)のデータ構造
struct Resource: Identifiable{
    let id =  UUID()
    let rid:  String //リソースID
    let name: String //会議室名
    let mail: String  //リソースのメールアドレス(=カレンダー取得のキー)
}
//User(Directoryから取得した社員)のデータ構造
struct User: Identifiable{
    let id =  UUID()
    let mail: String //メールアドレス
    let name: String //氏名
}

//braver一人ひとりのデータ構造
struct Braver: Identifiable{
    let id = UUID()
    let mail: String
    var name: String
    var startDate: Date  //会議が始まる時間
    var score: Int       //現在時刻からの時間的距離(単位:秒)
    var location:String  //会議が行われる場所
    var image:UIImage?   //サムネ画像
}

//Google APIよりカレンダーデータを取得して保持する。
class BraverByGoogle: ObservableObject{
    
    var nowDate:Date!
    var resourceList: [Resource] = []      //会議室リスト配列
    var userList: [String: User] = [:]     //全社員(Braver)リストDictionary メアドと名前のリストを保管
    
    @Published var braverList: [Braver] = []    //メインとなるbraverリスト配列。一意でMTGが近い順に並べる
    @Published var networkError: Bool = false   //ネットワークエラーならtrueとしviewでアラート表示する
    
    private var authorization: GTMAppAuthFetcherAuthorization? //OAuth認証済み情報。一度取得したらローカル保存
    private var currentAuthorizationFlow: OIDExternalUserAgentSession? //認証セッションの情報
    typealias callBackAlias = ((Error?) -> Void) //クロージャ共通宣言
    
    //Google Calenderから取得する予定データの構造体
    struct GoogleCalendarEvent {
        var id: String
        var name: String
        var startDate: Date?
        var endDate: Date?
    }
    private var backViewController: UIViewController?  //呼び出し元クラスのViewを保持。OAuthでログイン画面表示制御に活用
    private var googleCalendarEventList: [GoogleCalendarEvent] = []    //取得してきた予定データの配列
    
    //初期データ処理。Viewを預かり、OAuthを確立する。接続済みならローカルから取得。ない場合はOAuthログイン
    func setBraversByGoogle(viewController: UIViewController){
        
        print("init処理を行います")
        self.braverList.removeAll() //取得のたびにリストを初期化しておく
        networkError = false        //ネットワークエラー状態の場合も初期化
        nowDate = Date()   //日付をセット(デバッグの場合はデバッグ定義した日付をセット)
        
        //今日の日時(デバッグ用)をセット
        if DEBUG_TODAY != nil {
            let formatter = DateFormatter()
            formatter.dateFormat = "yyyy-MM-dd'T'HH:mm"
            formatter.timeZone = TimeZone(identifier: "Asia/Tokyo")
            nowDate = formatter.date(from: DEBUG_TODAY)! //デバッグ用
        }
        self.backViewController = viewController //呼び出し元のViewをクラス変数で預かる
        
        //ローカルから過去のOAuth認証情報を取得してGoogleへ接続
        if GTMAppAuthFetcherAuthorization(fromKeychainForName: "authorization") != nil {
            self.authorization = GTMAppAuthFetcherAuthorization(fromKeychainForName: "authorization")!
            self.setBraversList()
        }
        
        if self.authorization == nil { //もしローカルになければOAuth接続した上でGoogleへ接続
            showAuthorizationDialog(callBack: {(error) -> Void in
                self.setBraversList()
            })
        }
    }
    
    //OAuthへの接続処理。ログイン成功したらOAuth情報をローカル保存
    private func showAuthorizationDialog(callBack: @escaping callBackAlias) {
        
        let configuration = GTMAppAuthFetcherAuthorization.configurationForGoogle()
        let redirectURL = URL.init(string: URL_SCHEME + ":/oauthredirect") //ログイン成功後戻りURL
        let request = OIDAuthorizationRequest.init(configuration: configuration,
                                                   clientId: CLIENT_ID,
                                                   scopes: SCOPES,
                                                   redirectURL: redirectURL!,
                                                   responseType: OIDResponseTypeCode,
                                                   additionalParameters: nil)
        print("requestをセットしました..\(request)")
        
        //OAuthログイン。ログイン画面を呼び出し元Viewに表示させた後、Schemeで戻す
        currentAuthorizationFlow = OIDAuthState.authState(
            byPresenting: request,
            presenting: self.backViewController!,
            callback: { (authState, error) in
                if let error = error {
                    print("接続エラーです:\(error)")
                    self.networkError = true
                } else {
                    if let authState = authState {
                        print("認証成功しました:\(authState)")
                        // 認証情報オブジェクトを生成し、ローカル保存
                        self.authorization = GTMAppAuthFetcherAuthorization.init(authState: authState)
                        GTMAppAuthFetcherAuthorization.save(self.authorization!, toKeychainForName: "authorization")
                    }
                }
                callBack(error)
            })
    }
    
    //Google APIより会議室データを取得し、参加者braverをクラス変数に保存する
    func setBraversList() {
        
        //まずは会議室情報、全社員情報をGoogle APIから取得してからresourceList,userList辞書にセット
        self.setResources(callBack: {(error) -> Void in
            self.setUsers(callBack: {(error) -> Void in
                
                //現在から30分前からの全データを取得(ちょっと前のMTGまで念の為取得する)
                let startDateTime = Calendar.init(identifier: .gregorian).date(byAdding: .minute, value: -30, to: self.nowDate)
                //現在から5時間後までの全データを取得
                let endDateTime = Calendar.init(identifier: .gregorian).date(byAdding: .hour, value: +3, to: self.nowDate)
                
                print("会議室のCalendarを取得します")
                //会議室のメアドをキーにカレンダー予定を取得、参加braverを配列へセットする
                for resource in self.resourceList{
                    print("会議室:\(resource.name),mail=\(resource.mail)の予定を取得します")
                    self.setBraverByCalendar(calendarId:resource.mail, startDateTime: startDateTime!, endDateTime: endDateTime!)
                }
            })
        })
    }
    
    //Directory APIより会議室の一覧を取得
    private func setResources(callBack: @escaping callBackAlias) {
        print("会議室取得のため、Directoryに接続します")
        let directoryService = GTLRDirectoryService()
        directoryService.authorizer = self.authorization
        directoryService.shouldFetchNextPages = true
        let query = GTLRDirectoryQuery_ResourcesCalendarsList.query(withCustomer: "my_customer")
        
        directoryService.executeQuery(query, completionHandler: { (ticket, directory, error) -> Void in
            if let error = error {
                print("接続エラーが発生..\(error)")
                self.networkError = true
                
            } else {
                print("会議室を取得します")
                if let directory = directory as? GTLRDirectory_CalendarResources, let items = directory.items {
                    self.resourceList.removeAll() //現状のリストを初期化
                    
                    for item in items {
                        let rid:  String = item.resourceId ?? ""
                        let name: String = item.resourceName ?? ""
                        let mail: String = item.resourceEmail ?? ""
                        self.resourceList.append(Resource(rid:rid,name:name,mail:mail)) //会議室配列に保管
                        print("会議室:\(name),\(mail)")
                    }
                }
            }
            callBack(error)
        })
    }
    //Directory APIよりbraver全員の一覧を取得(予定データにて氏名がnilのケースがあるので補完のため)
    private func setUsers(callBack: @escaping callBackAlias) {
        print("全User取得のため、Directoryに接続します")
        let directoryService = GTLRDirectoryService()
        directoryService.authorizer = self.authorization
        directoryService.shouldFetchNextPages = true
        let query = GTLRDirectoryQuery_UsersList.query()
        query.customer = "my_customer"
        
        directoryService.executeQuery(query, completionHandler: { (ticket, user, error) -> Void in
            if let error = error {
                print("接続エラーが発生..\(error)")
                self.networkError = true
            } else {
                print("全Userを取得します")
                if let user = user as? GTLRDirectory_Users, let items = user.users {
                    self.userList.removeAll() //現状のリストを初期化
                    
                    for item in items {
                        let mail: String = item.primaryEmail ?? ""
                        let name: String = item.name?.fullName ?? ""
                        self.userList[mail] = User(mail:mail,name:name) //社員リストに保管
                        print("社員:\(name),\(mail)")
                    }
                }
            }
            callBack(error)
        })
    }
    
    //指定されたカレンダー(会議室カレンダー)に登録されているEventよりbraverを抽出しリストへセットする
    private func setBraverByCalendar(calendarId:String, startDateTime: Date, endDateTime: Date) {
        
        //Google CalenderAPIへ接続
        print("カレンダーに接続します!")
        let calendarService = GTLRCalendarService()
        calendarService.authorizer = self.authorization
        calendarService.shouldFetchNextPages = true
        let query = GTLRCalendarQuery_EventsList.query(withCalendarId: calendarId)
        query.timeMin = GTLRDateTime(date: startDateTime)
        query.timeMax = GTLRDateTime(date: endDateTime)
        
        calendarService.executeQuery(query, completionHandler: { (ticket, event, error) -> Void in
            if let error = error {
                print("\(error)")
            } else {
                if let event = event as? GTLRCalendar_Events, let items = event.items {
                    //会議室の予定に含まれるすべての参加者をチェックしていく
                    for item in items {
                        
                        //dump("dump item attendee...\(item.attendees)")
                        let name: String = item.summary ?? ""
                        let startDate: Date? = item.start?.dateTime?.date
                        let location: String = item.location ?? ""
                        
                        if startDate == nil {continue} //日付未入力はスキップ
                        
                        print("loc:\(location),name:\(name),\(startDate!)")
                        
                        if item.attendees != nil {
                            item.attendees!.forEach({(attendee) in //予定から全参加者の抽出
                                if attendee.email != nil && self.userList.keys.contains(attendee.email!) {  //社員リストに当メアドがなければ非社員として無視
                                    
                                    //参加者braverをbraverList配列へ登録する (APIバグで氏名がnilのケースがあるので社員リストから指名を取得
                                    self.setBraver(mail:attendee.email!, name:self.userList[attendee.email!]!.name, startDate:startDate!, location:name+":"+location)
                                    print("attendee:\(self.userList[attendee.email!]!.name),\(attendee.email!),\(location)")
                                }
                            })
                        }
                        print("loc:\(location),name:\(name),\(startDate!)")
                    }
                }
            }
            //直近の予定者ほど上に表示させるため、Scoreで昇順ソート
            self.braverList.sort(by: {$0.score < $1.score})
        })
    }
    
    //名前とメールアドレスをbraverListに登録していく。重複は排除。
    private func setBraver(mail:String, name:String, startDate:Date, location:String){
        
        let replacedName:String = name.replacingOccurrences(of:"(bravesoft)", with:"") //(bravesoft)を消す
        let score:Int = abs(Int(nowDate.timeIntervalSince(startDate))) //現在時刻と予定日時の差分。スコアが小さいほど直近なので優先表示
        var braverIndex:Int? = searchBraverIndex(mail:mail, braverArray:self.braverList) //配列内に既に存在するbraverか判定
        print("setします..name:\(name),startDate:\(startDate),location:\(location),score:\(score)")
        
        //既に存在する場合で、score判定によりより直近の予定なら予定日時を上書き。存在しないなら配列に追加する
        if braverIndex != nil {
            if(self.braverList[braverIndex!].score > score){
                print("braverをより直近として上書きします..name:\(name),startDate:\(startDate),location:\(location),score:\(score)")
                self.braverList[braverIndex!].startDate = startDate
                self.braverList[braverIndex!].score     = score
                self.braverList[braverIndex!].location  = location //会議室の場所も直近の予測として保存
            }
        }
        else{
            print("braverを登録します..name:\(name),startDate:\(startDate),location:\(location),score:\(score)")
            //取得したbraver情報を格納。(サムネイルは一旦デフォルト画像.後で差し替える)
            let braver = Braver(mail:mail, name:replacedName, startDate:startDate, score:score, location:location, image:UIImage(named:"defaultuser"))
            braverIndex = self.braverList.count  //setBraverImageにこのbraverの配列上の位置を伝える
            self.braverList.append(braver)
        }
        setBraverImage(mail:mail)  //braverのサムネイル画像を取得してbraverList配列へセットする
    }
    
    //配列内の構造体に、特定のmailの値がすでに存在するかチェック
    func searchBraverIndex(mail: String, braverArray: [Braver]) -> Int? {
        return braverArray.firstIndex { $0.mail == mail }
    }
    
    //braverのサムネ画像を取得してセット.API的にbraverそれぞれ1件ずつ取得してセットしていく
    func setBraverImage(mail:String){
        print("ユーザ画像を取得します:\(mail)")
        let directoryService = GTLRDirectoryService()
        directoryService.authorizer = self.authorization
        directoryService.shouldFetchNextPages = true
        let query = GTLRDirectoryQuery_UsersPhotosGet.query(withUserKey: mail)
        
        directoryService.executeQuery(query, completionHandler: { (ticket, directory, error) -> Void in
            if let error = error {
                if error.localizedDescription.starts(with:"Resource Not Found") {
                    print("画像が見つかりません(404)")  //画像が無いと404が返されるが、エラー処理にはしない
                }
                else{
                    print("接続エラーが発生..\(error)")
                    self.networkError = true
                }
            } else {
                print("ユーザ画像を取得します")
                if let directory = directory as? GTLRDirectory_UserPhoto, let photo = directory.photoData {
                    var urlSafePhoto = photo.replacingOccurrences(of: "-", with: "+")  //URL Safeを解除したBase64文字列に変換
                    urlSafePhoto     = urlSafePhoto.replacingOccurrences(of: "_", with: "/")
                    let imageData = Data(base64Encoded: urlSafePhoto)
                    let braverIndex:Int! = self.searchBraverIndex(mail:mail, braverArray:self.braverList) //このbraverの場所を探す
                    self.braverList[braverIndex!].image = UIImage(data:imageData!)!  //配列内に既に存在するbraverか判定
                }
            }
        })
    }
}

以上2つの主なSwiftファイルだけで受付アプリが出来て、100万円の価値を発揮する。

Enjoy!

コロナを正しく恐れるための7つの事実 #29

最良の思考は孤独の中でなされる。最悪の思考は騒動の中でなされる。
トーマス・エジソン

コロナ禍もそろそろ1年半。ようやく「ワクチン接種したよ」という声も周囲から聞こえ始め、長いトンネルの先に希望の光が見えてきた。

でも、「夜明け前が一番暗い」という言葉のように、
最近の日本はオリンピックだの変異株だので暗中大騒ぎになっている。

とあるアンケートでは8割の人がオリンピックを中止すべきと答えた。
オリンピックに前向きな発言をすると叩かれる空気を感じる。

・・でも、僕が思うにオリンピックは開催して良いと思う(延期のほうがベターだが)。

こんな些細な意見でも言い出しにくい社会は、もはや異常事態とも言える。
ついには感染を広げたことを理由に自殺する人まで出てきてしまった。

療養中の女性自殺「職場に感染広げたのでは…」思い悩む言動、メモも発見

そこで今回はコロナの9つの事実を知ることで、少しでも読者の不安を解消したい。そして「日本人は世界で劣ってもないし、むしろ誇っていい」ことも示したい。

リスクはリスクとして正しく認識して、正しく恐れよう。

①日本人はコロナに強い

日本人はコロナに強い。

これは先進国(G8)における2021年5月時点のコロナ死亡者数の比較グラフ。

日本より人口の少ないイギリスやフランスに比べても1/10程度に抑えている。

2020年2月に世界中が感染した時に生き残るのは日本人と書いた通りになってしまった。
(※アジア人はもともと体質的にコロナ耐性が強い説もある)

ただし、いくら感染を抑えても経済が死んでしまえば今度は失業者と自殺が増える。

そこでいくと実は日本は経済ダメージも少ない。下記のグラフはG8の経済ダメージ(実質GDPの落ち込み)を比較したもの。(単純な昨年対比ではなく、「2019年と同率で2020年が成長した場合と、実際の落ち込みの差異」)

日本が最も経済ダメージを抑えている。アメリカやロシアも抑えているが代償として死亡者が多い。逆にカナダは死亡者は少ないが代償として経済が落ち込んだ。

日本は死亡者も経済の落ち込みもG8で1番少ないのだ。

②日本は「米国の日常レベル」の水準を堅持

2021年4月、米国でワクチン摂取が進んだ結果、テキサスの大リーグチームは観客数制限を完全撤廃し、3.8万人(ほぼ満員)収容で試合は開催された。

この時点のアメリカで1日の感染者数は6万人。そして2ヶ月経った現在は1日感染者数は3万人以下に減少。もはやすっかり日常を取り戻しコロナの話題は過去のものになりつつある。

下記は「コロナ」の検索件数の日本とアメリカの比較。

これを見ると、「アメリカはコロナに勝利。日本はまだまだ」と思ってしまうことだろう。

ところが、現在(5/30)でも1日あたりの感染者数はアメリカが日本より多い。

日本は1日4000人程度で減少傾向。アメリカも減少中だがまだ新規感染は1日2万人もいる。

そう、日本は「アメリカの日常レベル」を一度も超過せず堅持してきたのだ。

みんなでキッチリ自粛しコロナを制圧。ワクチンも他国に一歩譲る日本。

かっこいいではないか? みんな、本当によくがんばっている。
なすべきことは犯人探しではなく、ヒーロー探しなのだ。

スタジアムを満員にしてノーマスクで試合を楽しんでいるアメリカ(1日の感染者:2万)。
2ヶ月後のオリンピックは中止にすべきだと騒いでいる日本(1日の感染者:0.4万)。

なんとも対照的だ。

③日本は法でなく「思いやり」で自粛できる

日本がコロナと経済を両立できた理由は、GoToや給付金など財政出動も影響しているが、さらに重要なことは

「一人ひとりが思いやりで最適解を形成していく日本的カルチャー」

にあると思う。

日本は、憲法で人権が保護されており他国の戒厳令(=非常事態宣言)のように個人の行動を強制的に制限することは出来ない。

下記に各国の非常事態宣言を整理した。
日本だけが強制ではなく「自粛お願いします宣言」を発令し、誰もが自主的に自粛した。

5月末現在、緊急事態宣言下でも街に人が溢れている。それでも第4派は収束しつつある。

街を歩いているのはエゴの塊ではなく、思いやりである。
感染は避けたい、でも少しは出かけたいし、いきつけの店は少しでも助けたい。

100人いれば100通りの自粛の仕方がある。統一のシンプルなルールを当てはめるより、それぞれ思いやりベースの自粛行動をしたほうが合理的ですらあるのだ。

ルールには限界がある。ルールでは無限にある状況を指示しきれない。密を避けた青空パーティならやってもいいかもしれないし、逆に高齢者等の健康に不安がある人が近くにいるなら、緊急事態宣言が出ていなくても強い自粛をすべきだろう。それらすべてをルール化することはできない。

また強引なルールを矯正した結果、経済も落ち込むだろうし、反発心から逆に地下で盛大な3密パーティが秘密の内に開かれてしまうかもしれない。

厳格なルールではなく、想いやりをベースとした緩やかなカルチャーで、我々はワクチンも使わずコロナを4回もしっかり抑え込み、経済打撃も最小限に抑え、そして現在はスゴい勢いでワクチン接種を進めているのだ。

もっと誇ってよいのでは?

④コロナ死亡者の85%は70代以上

下記、厚生労働省の公表資料を元に作成されたグラフは、コロナで死亡した人は、「実はコロナがなくても死亡した可能性」を示している。

2019年の全ての死亡者の85%程度は70代以上
2020年のコロナ死亡者の85%程度は70代以上

※引用:ダイアモンド社「日本のコロナ自粛がどう見てもバランスを崩している理由」

もし、コロナで50,60代の死亡率が大きく高まったとしたら、「コロナは死に至る病」と強く恐れる必要がある。しかし、データを見る限りそうではない。

コロナは死亡リスクが高い人の体力を奪い死亡させてしまう感染病なのだ。逆に言えば死亡リスクが少ない人を死亡させてしまうほど強くはない。

だからと言ってコロナは恐ろしくないと言うつもりはない。
緊急事態宣言等で抑え込み少しでも犠牲を防ぐべきだ。

ただ、「コロナはどんな人でも死に至る伝染病」と言うなら恐れ過ぎだ。
「コロナがきっかけだったが、天寿をまっとうした」と本人は感じているかもしれない。

⑤コロナで日本の死亡者は減少した

下記、厚生労働省の公表資料を元に日経新聞が作成したグラフによれば、

2020年はコロナで死亡者が増加したが逆にインフル他を理由とする死亡者は減少。
結果として、2020年は例年より年間死亡者は大きく減少した。

引用:日経新聞「年間死亡数11年ぶり減 コロナ対策で感染症激減」

ざっくり言うと、2020年に起きたことは

コロナ死亡者が3000人増加
インフルエンザ死亡者は5000人減少

インフルエンザで死亡する可能性があった層が、コロナで死亡したと見ることができる。
それにしてもこれだけ恐れられるコロナが逆に死亡者を減少させたというのは皮肉的。

勿論それでもなお、コロナは恐ろしい伝染病だ。
しかし「インフルエンザと比較できないほど非常に恐ろしい」という程でもなさそうだ。

⑥コロナの致死率はインフルエンザの3倍

ワシントン大学の研究によればコロナの致死率はインフルエンザより3倍高いそうだ。

ダイアモンド「新型コロナ、致死率はインフルエンザよりもはるかに高い」

はるかに高いといえど、3倍である。

日本社会のコロナに対する恐怖心はインフルエンザの100倍ぐらいではないだろうか?

あなたの体力では、3回インフルエンザになると1度は重症化するか?
もし答えがNOであれば、コロナはあなたにとっては大きなリスクではない

世界最強の体力自慢が集まり無観客で実施するイベントを中止する必要があるだろうか?
コロナへの恐怖やストレスのはけ口として現代の魔女狩りにあっているとは言えないか?

⑦若者感染者26万人中、死者は85名

2021年3月24日時点の厚生労働省の発表によれば、約1年間でコロナに感染した若者は26万人いる。そのうち死亡したのは85名だ。

糖尿病の基礎疾患を持つ20代の力士が亡くなったというニュースもあったが、26万人中、何らかの基礎疾患を持つ85名だけが亡くなったと思われる。

日本では毎年30万人が交通事故にあい、3000人が死亡する。若者にとってはコロナより自動車のほうがよっぽどリスクが高いのだ。

ちなみにここで言う「若者」には「〜49歳」まで含めている。

下記に厚生労働省の発表からこの1年間の感染者1万人あたり死亡率を世代ごとに整理した。
たとえば30代の1万人がコロナに感染したとして、死亡するのは2.6名だ。

若者にはコロナのリスクは小さいが、80代なら40代の10倍以上まで死亡リスクが高まる。
それでも80代以上の8割以上はコロナから回復して帰ってくる

お正月のお餅から始まり、あらゆる行動には必ず死亡リスクがついてまわる。
大切なことは思考停止にならずにリスクの大きさを正しく知っておくことだ。

最優先は高齢者へのワクチン

9の事実から分かることは、コロナ問題とは(既によく言われてるように)「高齢者(や健康不安な人)の感染をいかに防ぐか」に尽きる問題である。

若者の感染が怖いというよりは感染者数が増えて高齢者に感染させるのが特に怖いのだ。

つまり高齢者へのワクチン接種が完了する7月末が、我々がコロナに勝利する時だ。
政府は7月末までに完了できる見通しの中でオリンピックを開催するつもりなのだ。

コロナを正しく恐れるなら、高齢者との接触が多い人はやはり強めに自粛するべきだろう。
接触機会が少ないのであれば、過度な自粛より、バランスを見て経済を回すべきだ。

経済経済というと社長のポジショントークのようだが、命の問題でもある。

さっきの統計によると若者の3000人がコロナに感染すると、1名が死亡してしまう。
そして総務省の発表資料を元にした記事によれば300人が失業すると、1名が自殺してしまう

若者にとってはコロナ感染より失業のほうが大きな問題である。
自分自身、失業するよりコロナに感染するほうがマシだと思ってしまう。
コロナで壊滅的な打撃を受けた中小企業の被害は少しずつ大きくなっている。これからは失業者による自殺や犯罪のほうがコロナより大きな問題になるかもしれない。

東京は今日も緊急事態宣言の中、街に電車に人が溢れているが、それでも感染者は減り続けている。よしこの調子だ。経済も大事にしよう。感染者数が日常レベルのうちはそれぞれ思いやりベースで最適なバランスで行動すれば良い。(もし圧倒的に感染者数が増えてしまったら、他国同様により強い自粛措置が必要な可能性はあるが、今回は出番がなさそうだ)

感染者数が増えてきたら緊急事態宣言を出し、それぞれがバランス良く自粛する日本の作戦は、一見場当たり的だがとっても合理的であり、実際にワークしているのだ。

良いぞ日本。

さて、それにしてもこのままオリンピックは中止になってしまうのだろうか?

見えない敵を前に肥大化し暴走する日本社会のヒステリーが、
この日を夢に幼少から走り続けたアスリートの一生の晴舞台を壊さぬよう願う。

【コロナ後のイベントはどうなる?】 リアルに戻るイベント or オンライン化するイベント #28

ようやく「ワクチン」という希望の光が差してきた今日この頃、つよつよビジネスパーソンとしては、コロナ後の社会を予想して先回りしておきたいところです。

イベントはオンラインで十分なのか?

あらためて、コロナは人類史上稀に見る社会実験だったと思います。(いまだ進行中)

中でも特に関心の高いテーマの1つが

「イベントはオンラインで十分なのか?」

です。

この1年で相当数のイベントがオンライン開催となり、その中には成功したもの、イマイチだったもの、など様々な結果がでました。

そしてコロナ後はどうなるのか? 

イベントにもよりますが、大まかに分類すると、「リアルに戻る」 or 「オンライン化する」 or 「ハイブリッド化する」のいずれかに分かれそうです。

入社式、セミナー、展示会、結婚式など、それぞれどのように進化するでしょうか?

そこで、イベントをDXする自社プロダクト「eventos」を提供し、東京ゲームショウや東京モーターショー、東京ガールズコレクション等、多くのイベントに関わり(たぶん)日本一イベントを考えているIT起業家として、コロナ後のイベントを予想してみたいと思います。

ちなみに、僕らは2015年から「イベント×DX」を進めてきましたが、コロナ禍で「オンラインイベント」のニーズが急に高まり、問い合わせ数は倍以上に跳ね上がりました。東京ゲームショウ2020は初のオンライン開催となり、eventosでオンライン会場を担当しました。その他にも数多くのオンラインイベントを支援しました。

2020年は誰もが一度はオンラインイベントを体験したのではないでしょうか?イベント業界はDXが遅れていましたが、いきなり5年ぐらい未来にワープした感覚です。

ただし、「これからはすべてがオンラインだ」などと豪語する人もいますがそれは短絡的です。(例えば、20年前ぐらいに「10年後には本屋もテレビもなくなる」と豪語する人もいましたが、意外となくなっていません。)

変わるもの、変わらないもの。グラデーションをイメージし、解像度を上げましょう。

ということで、コロナ後のイベントがどうなるか、僕の予想を発表します。

リアル・オンライン対比マップ

さて、さっそく結論ですが、リアル・オンライン対比マップをつくりました。

(クリックで拡大)


イベントを分類するために、2つの軸を作りました。

縦軸の「↑参加・体験 VS 受動・視聴↓」

縦軸はイベントへの参加スタンスの違いです。

上に行くほど、食事をしたり交流したり、リアルで体験するイベントで、下に行くほど、ただ視聴するだけだったり受身なスタンスで参加するイベントです。

参加・体験型のイベントほど、オンライン化が難しく、リアルで参加したくなります。

例えば、

・手にとったり、食べたりはオンラインではできない(例:肉フェス)。
・議論や交流はやっぱりリアルの方が盛り上がる(例:ビジネス交流会)。

という感じです。

横軸の「←論理・仕事 VS 感情・生活→」

横軸はイベントの目的や性質の違いです。

右に行くほど、感動したり生活に身近なtoC系イベントで、左に行くほど、論理的で、仕事系のtoB系イベントです。

論理・仕事型のイベントほど、オンライン化しやすい傾向があります。

例えば、

・ビジネスの成果がでるならオンラインでも別に構わない(例:説明会)
・合理的に結論が出ればオンラインでも目的は達成できる(例:株主総会)

という感じです。

こうして2つの軸をもとにA,B,C,Dの4つのグループに分類しました。

そしてそれぞれのグループに個人的見解で各イベントをマッピングしました。
(どのイベントがどのグループに属するかは人によって意見が分かれると思います。)

グループA: 「絶対リアル」グループ

グループAは、「参加・体験型」×「感情・生活」のタイプです。

このグループは絶対にリアルで開催したいグループです。

たとえば結婚式。リモートでやろうと思えばできますが、起立して乾杯、写真ラッシュ、ブーケトス、新婦スピーチ等々、リアルで体験する感動は到底オンラインで再現できません。

実際、2020年に↓のイベントは、オンライン開催ではなく「中止」になりました。

肉フェス、フジロックフェス、ねぶた祭り、当社社員の結婚式・・。

グループB: 「リアル+オンライン」グループ

グループBは、「受身・視聴型」×「感情・生活」のタイプです。

このグループは、オンラインでもいいけど、リアルのほうが嬉しいグループです。

2020年のサザンや東京ゲームショウ等のオンラインイベントに参加した人は多く、「意外にオンラインでも楽しめた」という声をよく聞きました。これは新しい発見でした。

でも、「やっぱリアルで参加したい」という声もよく聞きました。他のリアルが皆無だったからオンライン参加したけど、リアルがあるならそっちを選択する、という人は多そうです。「オンラインはリアルを完全には再現できない」ということもはっきりしました。

オンライン vs リアルでは大きく体験価値が違うので、オンラインのチケット代はリアルの半額以下で提供されることが多くてリーズナブルです。そして、世界中どこからでも参加できる。会場キャパ制限が無い、などオンラインならではのメリットもあるので、オンライン参加のニーズはそれはそれで定着すると思います。

グループBの2020年のオンライン開催の事例は↓のとおりです。

サザンライブ:18万人がオンライン視聴(売上6.5億円)。
東京ゲームショウ:公式番組の総視聴数が3160万回(無料)。
東京ガールズコレクション:248万人がオンライン視聴(無料)。

あらゆるこの手のイベントがオンライン開催に挑戦したことは大きかったです。
グループBはコロナ後も基本リアル、そしてオンライン視聴も積極的に、となりそうです。

グループC: 「リアルorオンライン」グループ

グループCは、「参加・体験型」×「論理・仕事」のタイプです。

このグループは基本はリアル、場合によりオンラインでもOKなグループです。

例えば社員総会や記者会見などは、一応オンラインでもできるけど、リアルのほうが盛り上がるし効果も高いので、基本的にはリアルを志向します。

実際、コロナ禍でこのグループは大量にオンライン開催に移行しましたが、オンラインの限界を感じるという声もよく聞きました。

例えばとあるオンライン展示会では下記のような声が上がりました。

主催者の声:参加者は大量に来たがマッチングは少ない。誰が興味ある人かわからない。
出展者の声:ウェビナー視聴は多数あったが、商談には繋がらないし、印象も残らない。
来場者の声:ウェビナーは参考になったが、出展はあまり見ないし、飽きてきた。

このグループはリアルが基本ですが、例えば世界中からリモート参加させたい場合や、台風でリアルが危険な場合など、オンライン開催という選択肢は今後も有効と思います。そして、オンラインでも工夫次第で十分な成果を出すことは可能です。

介護大手のブティックスは365日開催のオンラインイベントCareTEX365をeventosで提供。「新市場の開拓で半年間で1億円の売上見込」という画期的なIRを発表しました。
(2020年11月IRより)

オンラインイベントの可能性を大きく感じる事例です。

リアルは短期開催。オンラインは長期開催とし、別々に扱っているところが特徴です。

グループBとグループCを比較してみると、どちらも「リアルとオンラインのハイブリッド」と言えるものの、その組み合わせ方は大きく違います。

グループBのハイブリッド:リアルを主体に、オンラインでも視聴可能(プロ野球)
グループCのハイブリッド:リアルorオンライン開催どちらかを選択(ビジネス交流会)

このグループは基本リアルに戻るものの、オンラインも織り交ぜる形に進化しそうです。

グループD: 「基本オンライン」グループ

グループDは「受身・視聴型」×「論理・仕事」のタイプです。

このグループはオンラインで十分目的が果たせるグループです。

例えば入社説明会のように、内容理解が主目的で、感動したり交流したりする必要がないイベントは、リアルで開催する意味がありません。

全員がオンラインを体験したコロナ後においては、このグループのイベントをリアルで開催すると、逆にストレスになりそうです。

たとえばセミナーは「見込み顧客を発掘する」という目的があります。そしてその目的を果たすには、リアルよりオンラインの方が効率が良かったりします。

あるセミナー上手な会社は、リアルからオンラインへの切替で↓の変化がありました。

○開催コストが下がった。 →セミナー回数が倍増。
○参加コストが下がった。 →参加者数が3倍に。
△参加者の熱量は薄い。  →商談化率は50%に半減。

◎結果、売上は2〜3倍に増加

リアルに比べて参加者の熱量は低くなるものの、参加者数と開催数が増えるので、結果的に受注を倍増することができました。

このグループはコロナ後も基本オンラインでやり続けるでしょう。

イベントをNew Normalへ

コロナ後のイベントを予想してみましたが、意外とリアルが戻りそうな結果になりました。
また、グループB〜Dのオンライン開催という新市場はコロナ後も拡大しそうです。

最後に強調したいのは、今後は「リアル vs オンライン」という二項対立ではなく、単に「イベントのDXを完遂しよう」ということです。テクノロジーでリアルはもっと便利に出来るし、オンライン開催も日常的に活用しましょう。

「イベントがアナログで不便」

そんな問題意識から2015年にeventosを開発して、イベントのDXを推進してきました。

イベントでよく見る↓のような不便なシーンは、デジタル技術で改善できます。

入場前の大行列
名刺2枚で入場
大きな地図で探すの大変
チラシをもらって袋に→結局捨てる
名刺交換 → ペンでメモ書き

リアルができなかった2020年、イベントDXの可能性は大きく開かれました。誰もがオンラインイベントを体験したこの1年は、イベントDXの起点となる革命的イベントでした。

そしてこの1年、リアルイベントが出来なかったことで、新しい出会いや体を震わせる感動などが減り、「社会の幸せの総量」は大きく減少してしまったと感じませんか?

「人類にイベントは必要である」ということもまた、はっきりした1年だったと思います。

安心してイベントが出来る日常は必ず戻ってきます。

イベントで社会をもっと元気にすべく、我々はテクノロジーを活用していきます。

手伝ってくれるエンジニアやイベンターの皆様、ぜひ当社の門を叩いてください!

————
この記事はイベントの役に立つ「eventosブログ」に寄稿予定だったものの、長くなりすぎたのでこちらに掲載することになったのだった笑

【Firebase+Node.js+LINE SDK】230行で東京都コロナ新規感染者数通知botを開発 (ソース付) #27

Firebase,Node.js等で開発中

コロナ感染者数を追いかけた1年

コロナに始まりコロナに終わった2020年、気づけば毎日、東京都の新規コロナ感染者数をチェックするのが日課になっていた。経営者たるものいち早く社会情勢をキャッチし舵取りをしなければならない。

毎日、15時頃に発表される新規感染者数の数値を、ニュースサイトをリロードしながら待つ。なぜか発表が15時より遅れる日もあって、やきもきしてしまう。

1年に1度、長期休暇中に一人ハッカソンを開催して何かしら開発してきた僕にとって、今年のテーマは迷いなく決定。この感染者数確認ルーチンの自動化だ。

毎日5分このルーチンにかかるとしたら1年間で実に30時間。これは自動化しなければ!

かくして年末年始、「東京都の新規コロナ感染者数を毎日毎分チェックし、新着感染者ニュースを検知したらいち早くLINEで速報を通知するサービス」を開発することにした。

一人ハッカソンの目的は新しい技術の学習でもある。今回は初めてFirebaseをつかってサービスを開発してみることに。

結果として、Firebaseの生産性はめざましく、コア部分はわずか3日で開発完了。ソースコードにして230行程度だ。当初想定の半分ぐらいの時間で完了したのだった。以下に開発・リリースまでの大まかな流れをまとめた。何かの参考になれば幸いだ。

コロナ感染者数通知ロボット「コロッチβ」

開発したサービスはその名も「コロッチβ」。サービス紹介サイト作った。

コロッチβ 〜東京都のコロナ感染者数通知サービス〜

※2023.04.30 コロナ収束につきサービス終了しました!※

無料で使えるので試しに利用登録してみて欲しい。(コロナ収束次第終了予定)

利用登録は下記QRをLINEで友達追加するだけ。とってもカンタン。

※2023.04.30 コロナ収束につきサービス終了しました!※

コロッチβがやってくれることは・・・

①15時頃に起動
②主要メディアを毎分チェックし東京都×コロナ関連記事を探す
③新着記事を発見したらLINEで知らせる
④2件通知したら本日分は終了
⑤たま〜にboketeのボケをランダムにLINEしてくる笑

という感じだ。

ちなみにサービスサイトはSTUDIOというWeb構築サービスで半日で構築。便利。

Firebaseの良いところ3つを解説

まだ未経験の人のために、Firebaseのナイスなポイントを3つ紹介しよう

①サーバ構築が不要
②よく使う機能が揃ってる
③負荷対策もバッチリ

①サーバ構築が不要

なんといってもこれにつきる。サーバレス開発。もともと「スマホアプリ側は開発するけどサーバ側はシンプルにデータ保存だけだから、いちいちサーバ構築&開発しなくて済ませたい」という怠惰系ニーズに応える思想で構築されているのがFirebase。サーバ構築にかかるストレスをとにかく削減できる。

例えばこれまで必須だった、サーバOS、Web、DB、バッチ、テスト、ログ、分析などの構築や設定が全部不要になるのだ。そしてもう1つのメリットとして、環境にまつわる設定をソースコードに記述することで、管理がシンプルでトラブルが避けられる。

これまでは、プログラムと環境構築がバラバラにやっていたので、「ソースの移行はカンタンだけど、環境構築どうやったか忘れちゃった」みたいなことがよくあった。

たとえば今回のコロッチβでのバッチとテスト環境の設定は下記のようにソースコードに書いて済ませた。DBの定義なんかもソース内で書いた。(今まではDBも事前構築が必要)

このソースをdeployコマンドで反映するだけでバッチ処理の設定が完了する(下記の例は、本番は13:00-19:00まで毎分実行、テストは1日中、毎分実行するという設定)

サーバレスなサービスの中でもFirebaseは特にシンプルで使いやすいと感じる。

②よく使う機能が揃ってる

アプリ開発者がよく思う、「こんな機能がもともとあれば楽だな〜」がガシガシ機能実装されていっているから、開発工程が大きく削減できる。

下記に提供されている機能をざっくりまとめてみた。

Cloud Firestore カンタン高速なDB
Cloud Functions APIやバッチ処理開発環境
Authentication ログイン・認証機構
Hosting WEBページ配信
Cloud Storage 画像や動画を保存・配信
Crashlytics 障害レポート・管理
Performance Monitoring パフォーマンス状況の表示
Test Lab 実機テストの代行
Google Analytics アクセス解析
Predictions ユーザ行動の予測・セグメント分析
Cloud Messaging プッシュ通知の配信
Remote Config 設定ファイルの管理・更新
Dynamic Links アプリ内部やDL用URLへのリンク
ML Kit 機械学習
A/B Testing A/Bテストの実行・分析

③負荷対策もバッチリ

サーバで意外と大変なのが負荷対策だ。事前のテストで本番当日の大量アクセスを再現するのが難しかったりするし、そもそも負荷の高いサービス運営経験が豊富な人は少ない。サーバを増やせば増やすほど構造が複雑になり、大企業のような様相を呈してくるのだ。

「シンプルな設計で作っておけば、自動でサーバを増設してさばいてくれる」

というFirebaseの設計思想は、まるでコンビニチェーンのように一気に店舗数を増やして大量の客(リクエスト)に対応できるのだ。

FirebaseのDBサービスである「Firestore」のドキュメントによれば、

完全な自動スケーリングに対応しています。現在のところ、約100万件の同時接続と、毎秒10,000回の書き込みまでのスケーリング制限があります

とのことだ。つまり大抵のサービスは負荷の心配がなくなる。これはアプリ開発者にとっては非常に嬉しいだろう。気になる料金も、下記の通り1週間程度で1万リクエストをさばいても14円程度だった。他のサーバ環境のサービスよりも、全然安く済んでしまう。

Puppeteerでスクレイピング&LINEで通知

Node.jsで提供されているスクレイピング用ライブラリのPuppeteerは、ライブラリ名がわかりにくいという点を除けば、簡単にスクレイピングができてとっても便利。また、LINEが提供しているNode.js向けのMessaging SDKも、予想よりも全然簡単に利用できた。

下記のソースを見てほしい。

「ボケてのピックアップにアクセスしてランダムで1ボケ拾ってLINE通知」

という処理が、たったこれだけのコードでかけてしまったのだ。

こちらはLINEの開発者向けリファレンス。日本人らしい丁寧なドキュメント

LINEとの連携設定。さすがUI/UXに定評のあるLINE。迷うことなく直感的に完了できた。

Firestoreにデータを記録

1日にLINE通知は2件も送れば十分。(あまり頻繁にくるとブロックされる)。LINEを送りすぎないようにするため&メディアへのアクセス回数も最低限にするためにも、新着記事の取得を記録しておき、本日のLINE通知が終了したらこれ以上、メディアへアクセスしないという制御をしておく。

Firestoreへのデータ保存のコードはこんな感じで簡単。NoSQLなのでDB構造もソースコード中で指定してひたすら保存していく。

保存されたデータは下記の感じ。covid_titlesというコレクションの中に1日1ドキュメントずつ追加していく。

ドキュメントの中身。メディアごとにドキュメント内コレクションを作成し、その中に記事取得の詳細を記録していく。

コロッチβの仕組みの図解

今回の仕組み図を解説すると下記のような感じだ。

ローカルPCにFirebase、Node.jsの環境を整備し、index.jsをプログラミングするだけで、環境設置が完了してしまうもだ。これまでLinuxサーバの設定やミドルウェア構築で大変だった時代と比較しても、カンタンになったものだ。。

ソースコードはコチラ!

indes.jsのソースコードを掲載。(LINEアカウント関連は伏せた)。ご参考に!


const functions = require('firebase-functions');
const puppeteer = require('puppeteer');
const admin = require('firebase-admin');
admin.initializeApp(functions.config().firebase);
const firestore = admin.firestore();
const line = require("@line/bot-sdk");

var isDEBUG = false;           //デバッグモード時はtrueとなり、各処理がデバッグモードで実行
var colName = "covid_titles";  //使用するfirestoreのコレクション名

//対象サイトドメイン, 記事一覧ページのパス, 記事タイトルのCSSクラス名, タイトルタグからAタグ位置のXPath記述, の配列
var mediaURLs = [
  ['www.fnn.jp'         ,'/category/news/',   '.m-article-item-info__ttl', './parent::div/parent::a'],
  ['www3.nhk.or.jp'     ,'/news/catnew.html', '.title',                    './parent::a'],
  ['www.nikkei.com'     ,'/news/category/',   '.m-miM09_titleL',           './parent::a'],
  ['www.asahi.com'      ,'/news/',            '.SW',                       './self::a'],
  ['news.tv-asahi.co.jp','/news_society/',    '.list-text',                './parent::a']
];

//LINEメッセージ配信用の設定
const config = {
  channelAccessToken: '**************',
  channelSecret:'**************'
};

//LINE関連 デバッグ用の別アカウント
const config_DEBUG = {
  channelAccessToken: '**************',
  channelSecret: '**************'
};

//firebaseのタイムアウトと使用メモリの設定
const runtimeOpts = {
  timeoutSeconds: 50,
  memory: '2GB'
}

/**
  コロナ記事チェック定期処理を本番モードで開始するよう登録する。13:00-19:00の間で毎分処理
 */
exports.covidCheck = functions.runWith(runtimeOpts).region('asia-northeast1').pubsub.schedule('every 1 minutes from 13:00 to 19:00').timeZone('Asia/Tokyo').onRun(async(context) => {

   isDEBUG = false;
   console.log("本番モードで定期処理を実行します");
   await covid19Check();
});

/**
  コロナ記事チェック定期処理をデバッグモードで開始するよう登録する
 */
exports.covidCheck_DEBUG = functions.runWith(runtimeOpts).region('asia-northeast1').pubsub.schedule('every 1 minutes').timeZone('Asia/Tokyo').onRun(async(context) => {

   isDEBUG = true;
   console.log("デバッグモードで定期処理を実行します");
   await covid19Check();
});

/**
  対象サイトに対してクローリングリクエストを送信していく
  */
async function covid19Check(){

  //クロール対象メディアすべてに対して調査
  for(let media of mediaURLs) {

    //YYYYMMDDをキーとし、ドキュメントを取得 > 更にサブコレでドメインをキーとして、本日すでに記事保存済みか判断
    const col = (!isDEBUG ? colName : colName+"_DEBUG");
    var docRef = firestore.collection(col).doc(YYYYMMDD()).collection("media").doc(media[0]);
    var snapShot = await docRef.get();

    //まだ今日firestoreに記事が登録されていないなら、記事を取得する
    if(snapShot.exists){
      console.log("既に記事が保存済みです:"+docRef.id);
    }
    else{
      await mediaRequest(media[0],media[1],media[2],media[3]); // スクレイピング
    }
  }
}

/**
  メディアへスクレイピングURLを送りコロナ記事を判定
  @param domain     記事掲載サイトのURL,IDとしても使う
  @param titlePath  記事一覧ページのパス
  @param titleClass 記事タイトルのCSSクラス名
  @param pathA      タイトルからAタグ位置のXPath記述
 */
async function mediaRequest(domain, titlePath, titleClass, pathA) {

  console.log("mediaRequest:%s,%s,%s,%s", domain, titlePath, titleClass, pathA);

  const browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox']});
  const page = await browser.newPage();
  await page.goto('https://'+domain+titlePath); //記事を取得するためメディアサイトへ接続

  console.log('接続しました:%s',await page.title());

  const titleLists = await page.$$(titleClass);  //記事タイトルのクラス名を指定して全タイトル取得

  //全記事タイトルについて、東京コロナ感染関連の記事があるか判定
  var maxTitle = titleLists.length;

  //(本番時は)上から10件の最新記事のみ記事取得をする。デバッグ時は全件取得。記事が10件無いケースを想定してmin
  if(!isDEBUG){ maxTitle = Math.min(titleLists.length,10); }

  for (let i = 0; i < maxTitle; i++) {  //昨日の記事を拾うことがあるので、上から10記事のみチェック

    const temp = await titleLists[i].$x("./text()"); //子要素も含んでいるので、本要素のtextのみ切り出し
    const titleProp = await temp[0].getProperty('textContent'); //記事タイトルタグのテキスト取得
    var titleText = await titleProp.jsonValue(); //テキストへ変換
    titleText = titleText.trim(); //前後の余白を除去

    //記事タイトルの特定文字(東京とか)から、対象記事かどうかを判定
    if(/東京/.test(titleText)&/感染/.test(titleText)&/人/.test(titleText)){

      console.log("title=%s",titleText);
      const parentA = await titleLists[i].$x(pathA);  //タイトルのタグからAタグへの相対距離をXPathで指定

      //Aタグが存在すれば(必ず存在するはずではある) タイトル保存&プッシュ送信へ
      if (parentA.length > 0) {
        const hrefProp = await parentA[0].getProperty('href'); //AタグよりURL取得
        const hrefText = await hrefProp.jsonValue();
        console.log("hrefText=%s",titleText,hrefText);
        await covidTitleAction(domain,titleText,hrefText);  //コロナ記事発見時の処理
        break;// 1メディア1記事配信した時点で終了する
      }
    }
  }
  return await browser.close();
}

/**
  コロナ記事発見時の処理
  @param domain   記事掲載サイトのURL,IDとしても使う
  @param title    記事のタイトル
  @param titleURL 記事のURL
 */
async function covidTitleAction(domain,title,titleURL){

  console.log("ドキュメントを登録します %s,%s:",title,titleURL);

  var titleCount = 1;  //本日の記事とロク数を保管

  //YYYYMMDDをドキュメントキーとする
  const col = (!isDEBUG ? colName : colName+"_DEBUG");
  var docRef = firestore.collection(col).doc(YYYYMMDD());

  //本日何件記事保存しているか(3件以上は送らない制御用)
  await docRef.get().then(doc => { titleCount = doc.data().count+1;})
                    .catch(err => { console.log('ドキュメント読み込みエラー', err); });

  docRef.set({
              updated : new Date(),
              count   : titleCount  //今日の記事登録件数を保存
            });

  docRef.collection("media").doc(domain).set({  //1記事ごとにサブコレとして登録
        title    : title,
        url      : titleURL,
        created  : new Date()
       }
  );

  //本日配信がまだ2件未満なら、記事をLineメッセージで全員へ配信
  console.log("Lineメッセージで配信します %s,%s:",title,titleURL);

  //Line配信の準備 本番かデバッグモードかで配信先を切り替える
  const client = new line.Client((!isDEBUG ? config : config_DEBUG));

  //(本番時は)本日最初の2記事のみプッシュ通知を送る。デバッグ時は全件送る
  if(titleCount <= 2 || isDEBUG){
      await client.broadcast({
        type:"text",
        text:title+"\n"+titleURL
      });
  }
}

/**
  今日の日付をYYYYMMDDで返す
  */
function YYYYMMDD(){
  var dt = new Date();
  return dt.getFullYear()
             +(('00' + (dt.getMonth()+1)).slice(-2))
             +(('00' +  dt.getDate())    .slice(-2));
}

/**
  ボケてチェック処理を開始するよう登録する
  */
exports.boketeCheck = functions.runWith(runtimeOpts).region('asia-northeast1').pubsub.schedule('every 1 hours from 9:00 to 23:00').timeZone('Asia/Tokyo').onRun(async(context) => {

  console.log("ボケて配信処理を実行します");
  isDEBUG = false;

  const RATE = 30; //何分の1で発動するかの設定。 2日に1件程度の確率に設定
  var seed = Math.floor( Math.random() * RATE );

  if(seed == 0){ await boketeCheck(); }
});

/**
   ボケてのピックアップをチェックする
  */
async function boketeCheck(){

  const browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox']});
  const page = await browser.newPage();

  await page.goto('https://bokete.jp/boke/pickup'); //記事を取得するためボケてへ接続

  console.log('接続しました:%s',await page.title());

  const bokeLists = await page.$$(".boke-text");  //ピックアップの全ボケ取得
  var idx = Math.floor( Math.random() * bokeLists.length ); //全ボケからランダムで1つ選ぶ

  const odaiHref = await bokeLists[idx].getProperty('href'); //記事タイトルタグのテキスト取得
  const hrefText = await odaiHref.jsonValue();

  //Line配信の準備 本番かデバッグモードかで配信先を切り替える
  const client = new line.Client((!isDEBUG ? config : config_DEBUG));

  //ボケをLineメッセージで全員へ配信
  console.log("Lineメッセージでボケを配信します:%s",hrefText);

  //ボケのLine配信
  await client.broadcast({
           type:"text",
           text:hrefText
  });

  return await browser.close();
}

湯ワーキングスペースBEST3【日帰り温泉編】をこっそり教えます #26

自宅作業でNew Normalな今日このごろ。
皆さんも様々な形でリモートワークしていることでしょう。

でも実は、「自宅」ってそんなに作業に向いている場所じゃないんです。

ときには、都心を離れて一人で集中作業したほうがいいときもあるんです。

どんな作業なら都心を離れたほうがいいのか?

それは、

製品企画
経営戦略
クリエイティブ

のような集中力&発想力が求められる作業。

本来こういう集中作業は自宅やオフィスなどの日常環境は向いてないのだ。

どんな環境が集中作業に向いているのか? 

それは「温泉」

日常を離れ思考をリラックスさせる「大自然」
裸になって一切の情報が遮断される「お風呂」

温泉こそが高いアウトプットを出せる「湯ワーキングスペース」なのだ。

もはや都心から日帰りできる範囲にある湯ワーキングスペースを複数知っていることは、現代のつよつよビジネスパーソンにとって必須であろう。

1時間も電車に揺られて時間がもったいないと思うかもしれないが、電車の中も思索に使おう。一切のしがらみを、怠惰な日常を、最寄り駅に捨てショートトリップへ旅立つのだ。

僕は大学卒業と同時に起業して、もう15年以上このスタイルで自由に働いてきた。オフィスや自宅も使いながら、ここぞ!という作業の時は温泉にでかけ、新製品や経営戦略などをねってきたのだ。(ちなみに自由が行き過ぎると逆にオフィスの貴重さにも気づく。それはまた別の機会に)

そんな僕が長年のリモートワークスタイルの中で発掘してきた、極上のオススメ湯ワーキングスペースのBEST3【日帰り温泉編】を、今回はこっそり教えることにしよう。

人が増えすぎると集中しにくいので本当は教えたくなかったが、コロナ不況で閉店されたらもっと困るので、やむなく発表に至った。

※ささやかな注意点がある。長時間いるのならお店側の利益にも配慮して、たっぷり食事や飲み物を注文すること。そして土日は人も多いし純粋に休みたい人の邪魔なので平日の日中がオススメ。

<選考基準>

①日帰り入浴可能な天然温泉
②都内から1時間程度で行ける
③静かで人が少なめ
④横になれる広い休憩スペース
⑤リモート会議ができる個室有
⑥PC作業&電源OK確認済

第三位 【熱海】日航亭・大湯

まず第3位はメジャーな温泉リゾート熱海から。熱海は東京から最も電車でアクセスしやすい有名温泉街。品川から新幹線で30分そこそこで到着できる。

そんななかで、地元民に愛されているのがこの大湯。かの家康公もよく通っていたというから大物系の湯ワーキングスペースなのである。

土日は観光客で賑わっておりあまり落ち着けないが、平日は地元のおじいちゃんたちがちらほらで静かな雰囲気だ。

建物も昭和のレトロな哀愁につつまれており、変に気張らずに田舎の実家に帰ってきた感じでくつろげる。

天下国家を左右する大きなプロジェクトは、家康も愛した大湯で決まりだ

Goodポイント:アクセスが良い、レトロな雰囲気
Badポイント:やや狭い、サウナが無い

日航亭・大湯の詳しい情報はこちらへ

第二位 【箱根】湯の里おかだ

第2位は「温泉の横綱」箱根から。

箱根には日帰り入浴できる素晴らしいスポットがたくさんがあるが、湯ワーキングに使うとなると快適なところは意外と少ない。

そんななかでも「おかだ」は全方位的に湯ワーキングニーズを満たしてくれる素晴らしいスポット。さらにサウナまで付いているなんてもはや奇跡。

電車で行くと箱根湯本駅から20分以上歩く必要があるが、レトロな温泉街を過ぎて小川のせせらぎの中、ささやかな山登りを満喫しよう。非日常はその奥に潜んでいるのだ。

箱根はいつも人で賑わっているが、おかだの平日の休憩スペースは静穏そのものだ。

静かな森林でデトックスワークをするなら温泉界のエースで4番、おかだで決まりだ

Goodポイント:全体的に品質高い、静かな環境
Badポイント:やや遠い、電源とWiFi弱い

第一位 【小山】思川温泉

満を持しての第1位は、小山にある思川温泉だ。え?なにそこ知らなかった?
そう、温泉としてはマイナーだからこそ静かで湯ワーキングスペースとしては王者なのだ。

関東圏の皆さんなら「おやーまゆーえんちー」というCMを見たことあるはず。残念ながら遊園地は2005年に閉園してしまったが、ここは跡地に出来た大人の遊園地。
おやま遊園地のCM

思川温泉のつよつよポイントは絶景。都内から1時間の場所に、まるで原始時代のような雄大に流れる川と広い温泉の開放感は、もはやアート。

さらに、ここはサウナも最高なのだ。アッツアツのサウナ室に、天然地下水+大きい釜水風呂の組み合わせ、そして大自然を見ながら開放的なリゾートチェアで「ととのう」贅沢。一歩間違うと日常に戻れなくなってしまうのだ。

(ちなみに、サウナは頭がボーッとしてしまうので作業中はあまりオススメしない。むしろ作業後のご褒美に活用しよう)

時空を超越し原始人となり、食堂の座敷か、絶景の個室を借りて集中作業しよう。

なにもかもから忘れ一つのことに思うとき、さあ思川を思い出せ。

Goodポイント:景色、サウナ、静か
Badポイント:休憩室のTV、個室高め

ということで、つよつよ湯ワーキングスペースBEST3をこっそり発表させていただいた。

ちなみに、湯ワーキング中は、スマホを脱衣ロッカーにしまっておこう。集中作業にとってスマホは敵でしかない。

作業に詰まったら裸になってボーッと景色を眺め、アイディアがでてきたら急いで服を来てMacへ猛ダッシュ。これを10回も繰り返す頃には、決して都心では創れない異次元のアウトプットが出来上がってるはずだ。

さあ、もう我慢も限界のはずだ。君も湯ワーキングライフをスタートさせよう☆

今回の記事が好評なら、【都内編】や【番外編】も発表しよう。

気になる方はつよつよ社長のtwitterのフォローよろしく。

もし、とっておきの湯ワーキングスペースを知ってる「つよつよ温泉ワーカー」いたら、ぜひこっそりDMで教えてほしい。

コロナ時代のNew Normal、すべての常識から開放されて、それぞれ個性あるNew Normalを実践されたし。

ババンババンバンバン!

ではでは

リーダーになるための5つの具体的TIPS #25

(社内の若手向けにブログを書いてくれと言われて月に1回程度書いている。誰かの参考になればと思ってこちらにもUP)

リーダーについて、いつかはやってみたい、でも向いてないかも
でもやってみようかな、の繰り返しでダラダラ時間が過ぎ去って行く人は多いです。

リーダーになりたくないっていう人もいますが、人はみなリーダーですよ。

あなたが休日にどこかに行くか決めるとき、あなたはあなたのリーダーです。
あなたが平日に嫌な仕事をやってるとしたら、上司があなたのリーダーです。
平日の仕事でも、せめて自分のリーダーぐらいは自分でやれるようになりましょう。

そのためにも、リーダー力を鍛えていきましょう。

でも、リーダーになるほどの力がない?

そう、

リーダーになればリーダー力は鍛えられる
でも、リーダー力がないから、いつまでもリーダーに任命されない

の典型的なデッドロックがよく起きているんです。

そこで、数多くのニューリーダーを育ててきた僕から見て、

具体的にどうすればリーダー力が鍛えられ抜擢されやすくなるのか?

5つの具体的アクションを伝授します。

<1>手を挙げる

はい、もう今日はコレで完結と言ってもいいぐらい大事なことです。

リーダーになりたいなら、面談や飲み会や発表会で口にしましょう。
それだけで打席は回ってきます。言えば伝わる。言わないと伝わらない。

静かに頑張ってればいつか誰かが推薦してくれるのかなと思ってる
人もいると思いますが、無いです。そんな奇跡を待つのはやめましょう。

僕は松本人志さんのファンでいつか一緒にアプリを作りたいと周囲に話していたら
人の紹介で本当に実現しました。偶然だけど必然。そんな経験を無数にしています。

手を挙げましょう。口に出しましょう。まずはそれからです。

<2>上司と飲みに行く

手を挙げたところであなたの視座が低いならリーダーにはなれません。

あなたが船に乗っている料理人としましょう。
船にはいろんな人が乗っています。

料理人
レストラン店長
航海士
船長
船会社の社長

この順番、どんな順番か分かりますか?
下に行くほど視座が高まり、見ている世界が広がっています。

実際に料理人から船会社の社長に上り詰めた人もいるでしょう。
そんな料理人が何をしていたか?

店長や航海士と飲みに行くんです。
そしたら、売上の話とか3ヶ月後のあるべき体制なんかの話が出てきます。

料理人同士で話すだけだと客や店長の愚痴で盛り上がるのが関の山。時間の無駄。
相手が偉すぎても話がチンプンかんぷんなので、ちょっと上ぐらいの上司がオススメ。

僕は学生バイトの時にその会社の部長と毎日飲みに行ってました。

なぜ部長はあの人をアサインしたのか。あの案件は今後どうなるのか
質問しまくってました。なんなら社長と同じ視座を持ちたいと思ってました。
その結果、社員より重要な仕事を任されるまでそう時間はかかりませんでした。

視座が低い社員は、いずれ視座が高いアルバイトの指示に従うことになるでしょう。
飲みが苦手な人は、ランチとか移動時間とかが狙い目です。

<3>GIVE&GIVE

いくらあなたが運良くリーダーになれたとしても、
実際のところ、あなたについていきたい人はいるでしょうか?

ちょっと不安ですよね?

どうすればあなたについていきたくなるのか。
答えはカンタンです。与えましょう。与えまくりましょう。

仕事は手伝ってあげ、技術は教えてあげ、手柄はくれてやれ、
困ってたら助けてやり、雑談は盛り上げてやりましょう。

そんなあなただったら、みんながついて行きたくなります。

bravesoftを創業する時、裸一貫、一人ぼっちになりました。
しかしすぐ、法政大学時代の仲間4人がいきなり入社してくれました。

たぶん、学生時代やアルバイト時代にギブをしまくった結果だと思います。
でも、下心があってギブってたわけじゃなくて、なんとなくです。

このブログも1つのギブです。書いて何のメリットが有るかよく分かってません。

でもいいじゃん。小銭ばっかり数えてないで、気前よくギブリましょうよ。
ギブって気持ち良いんです。

<4>売上を上げまくる

いくらあなたに人がついてきてチームが成立したとしても
勝てないリーダーについてきたい人はいません。

勘違いしないでください

優しいリーダーより、仲良いリーダーより、かっこいいリーダーより、賢いリーダより、、

「勝てるリーダー」にみんなついていきます。

売上はわかりやすい勝利です。

あなたがまだリーダーじゃないとしても、売上にこだわってください。
エンジニアでも売上にこだわることはできます。
営業が赤字案件とってきたとか顧客がバカだとか、言い訳してないで稼げるエンジニアになりましょう。

僕は学生バイト時代から売上にこだわってました。

開発成果がどのぐらい売上に貢献したか判断しにくい自社事業の場合なら、どんな機能をどれだけ開発したんだってことを自主的に可視化して、売上換算でこれぐらい開発しましたよ。
みたいに「価値を示す」ことにこだわる学生でした。

その後、起業したときには、わずか23歳のエンジニアに案件がどんどん集まってくれました。かつての同僚や顧客が案件を紹介してくれるんです。どんな状況でも「価値」と向き合う人には自然と良い仕事が集まってくるみたいです。

売上・成果・評判などなど、「価値」を示せる常勝リーダーを目指しましょう。

<5>心を磨く

もしあなたが勝てるリーダーになったとしても、あなたの性格が悪ければ、
いずれみんな去っていくか、盛大に裏切られるでしょう。僕のように。

心を磨くのは他人のためではなく、自分のためです。

社長にかかるストレスを説明する時には、

「旅行の幹事してたら、参加者は遅刻したり不満いう奴だらけ」

のときにかかるストレスの、だいたい100倍ぐらいですって説明してます。
リーダーとか幹事ってストレスかかるんです。どんどん性格が荒れてきます。

逆に言えば、社長とかリーダーがなぜ尊敬されるのかって考えたら、
そういうストレスに耐えてそれでも前を向ける器があるからですよね。
尊敬されるリーダーは相応の忍耐力をもっており、尊敬に値します。

心を磨くために、旅行の幹事を引き受けましょう。心を磨くんです。
あなたが幹事のストレスをいくら主張しても旅行は成功しませんよね。
理不尽に耐えながら、いかに楽しんでもらえるかだけを考えましょう。

「また一緒に旅行行きたいね」

そう言われることだけを考えましょう。
楽しい旅行を提供できるリーダーになったとき、自然と人は集まってきます。

僕はもともと性格が前向きでどこでも気に入られるタイプでしたが、
それでも20代は尖っていて部下に辛い気分にさせたことは結構あったと思ってます。

しかし、数々の裏切りや離反を体験した結果、

もっと心を磨かないと大成功は出来ないことを30歳あたりで理解しました。
成功をとるかワガママを通すか、一度考えてみることをオススメします。

・・・ということで、リーダーになるための具体的なTIPSをまとめてみました。

ようするに、

手を挙げて、上司と飲み、周囲を助け、売上を上げ、幹事をすれば

きっとあっという間にリーダーになれるでしょう。

もちろん、カンタンではないけど、難しくもないですよ。

やる気さえあれば誰でも立派なリーダーになることができるんです。

実はまだまだたくさんノウハウがあるけど、時間の都合でここまでにしておきます。

もっと詳しいノウハウを知りたければ、

つよつよchへのチャンネル登録をお願いします笑

「マネジメントが苦手」と思いこんでるエンジニアに言いたいこと #24

「ちゃんとマネジメントが出来るエンジニアって少ないよね」

これは日本人の共通見解だと思います。ホントに少ないんです。

「日本から新しいイノベーションが出てこないよね」ともよく言われます。
これも「マネジメントできるエンジニア不足問題」から来てます。

どういうことか?

いわゆるGAFAやSony、Hondaとか、大きな技術イノベーションを起こした会社って、

エンジニアが経営=マネジメントをしたパターンが大半なんです。

マネジメントって「チームで成功させる」っていう意味です。一人で出来ることに限界がある以上、

マネジメントできるエンジニア=成功できるエンジニア

っていうことなんです。

そこで今日は、なぜマネジメントできるエンジニアが少ないのか?

そしてその問題をどのように解決できるのか説明します。

最初に言っておきたいのは、全員がマネジメントできるようになる必要は全くありません。一匹狼やプレイヤーとして幸せになることはいくらでもできます。無理しないように。

でも、一流になるようなプレイヤーって実はマネジメントもできるんです。
マネジメント力も持ちつつプレイヤーに徹している人が一流プレイヤーには多いです。

とくに20代の成長期は、ぜひマネジメント力を身につけておきましょう。
身につけたあとでプレイヤーを演じてもいいんです。

マネジメント経験も無いのに「プレイヤーが好き」と言い切る人は人生の可能性を狭めています。

さてさて、それではまず、

「エンジニアはなぜマネジメントが苦手なのか?」

の理由を解明しましょう。

圧倒的な理由第1位は、

「心優しくて人に指示ができない。責任とか怖い。だから人の上に立ちたくない」

だからです。あなたもそうですよね?

(ちなみに理由第2位は「人の気持ちが分からない」です。これはまた別の機会に・・)

でも、安心してください。僕も昔そうでした。エンジニアは最初みんなそうなんです。
エンジニアって人見知りだし、神経質だし、臆病だし、内向的なんです。

言い換えるなら、内向的な人がエンジニアに向いてるんです。

で、問題はココからです。

内向的な人はマネジメントには向いていませんよね?
目的達成のためにはいろんな人に働きかける必要がありますから。

それでは、その逆の「外向的な人」はマネジメントに向いてるのか?

それがそうでもないんです。

感度が鈍い人、繊細さが無い人は、良いマネージャにはなれません。
いろんな人に働きかけられたとしても、その内容がイケてないからです。

ちょっと混乱してきましたか?

よし、「ウサギ」と「カメ」で例えましょう。

ウサギは耳とか目とか繊細です。高感度センサーがついてていろんなことに気付きます。
ちょっとしたそよ風にもビクンビクン反応し逃げ出します。

カメはだいぶ鈍感です。見えている世界は狭くいろんなことに気付きません。
滅多なことでは動揺しないけど、ピンチに気づかず敵にひっくり返されます。

これ、どっちがマネジメントに向いてると思いますか?

・・まあ、どっちも向いてないか(笑)

質問を変えましょう。どっちが良いマネージャーになる可能性が高いでしょう?

ウサギだと思いませんか?
高感度センサーを持ったウサギが、動じない精神力を手に入れた時、つよつよマネージャーになるんです。

内向的な人が外向的を身につけたほうがつよつよになるという研究結果は、メンタリストdaigoさんも説明しているので、詳しく知りたい人はこちらをチェック

内向的人間はすばらしい 脳科学で明かされた「リア充ざまぁ」な事実

エンジニア出身でマネージャとして活躍している人は、もともとは内向的なのに、ガンバって精神を鍛えた人だということを説明しました。

では、精神を鍛えるにはどうすればいいでしょう?

それも簡単です。慣れるだけです。

面接を思い出してください。
まあ人生最初の面接から1〜100回目の面接ぐらいまでは緊張しますかね。

でも、101回の面接はどうですか?もう結構なれてませんか?

なんのことはない、「習う」は「慣れる」ってことなんです。
マネジメントも同じことです。向いてないと思い込んで諦めて挑戦していないだけなんです。

・・それと皆さん、「優しいから向いてない」っていうゴマカシもやめましょう。

本当は優しいっていうのも違いますよね?本当は怖いだけなんです。
うまく出来ないマネージャのテンプレは、「頼りない父さん」パターンです。

子供が悪いことをしても叱れない。ちょっと良いことがあると過剰に褒める。何か失敗しても、環境が悪い、教育が悪い、国家が悪い、父さんが悪いといって本人に責任を軽くする。

これって優しいお父さんでしょうか?違いますよね?どっちかというと頼りないですよね?

厳しくても結果的に子供を成長させるお父さんが本当に良いお父さんですよね?

本当は嫌われるのが怖いだけなんです。心のセンサーが敏感すぎて少し悪口言われただけでも大打撃なんです。
優しいっていう言い訳はもう禁止にしましょう。言うべきことを言える人になりましょう。

「エンジニアには2種類しかいない。逃げる人か、挑む人か。挑む人だけがマネジメント力を持つ」

エンジニアの皆さん、「嫌われる勇気」を持ちましょう。

これまでも我々エンジニアの勇気がイノベーションを生み、社会を進歩させてきたんです。
ムッキムキのマッチョウサギに育ち、つよつよエンジニアとしてチームを勝利に導きましょう。

表面だけ優しい上司はただの臆病者です。
勝てる上司、厳しくても育てる上司が本当に優しい上司です。

ここまで読んで、少しずつマネジメントに興味が湧いてきたエンジニアもいるかと思います。

そういう人の背中を押す動画を収録しましたので、見てみてくださいね。

「マネジメントが苦手」と思いこんでるエンジニアに言いたいこと

ではでは!

コインハイブ(Coinhive)事件の意見書を公開します #23

最近ハッカーRADIOというラジオ番組をはじめたり、Youtubeつよつよちゃんねるを開設したりと「外交」を増やして良かったことはいろんな情報が入ってくることだ。

コインハイブ事件については、「へ〜なんかビットコインで悪い人が捕まったんだ」ぐらいに捉えていたが、一緒にラジオをやっている池澤あやか氏が怒っているという情報を聞き、Youtube番組内(今さら聞けない! 池澤あやかに聞く「コインハイブって何が問題?」)で詳しく聞いてみたところ、「たしかに警察やりすぎ」という印象。さらに一個人でしっかりと意見表明する池澤氏に勇気をもらったので、ここにブログ投稿&意見書も公開・提出する。

事件のカンタン説明

サイトに来た人のPCを無断利用して仮想通貨を稼げるのがコインハイブ

といってもそんなにたくさんは稼げない。数千円レベル

そんなにPCも重くならない。(重くなったら二度と見ないし)

なのに警告もなく「いきなり逮捕」された人が22名

うち1人のモロ氏が簡易裁判の判決を拒否し地裁へ

地裁は無罪獲得。高裁は逆転有罪。舞台は最高裁へ

ハッカー協会が4/1まで意見書を募集。まとめて最高裁に送る 
↑イマココ

ズバリ結論

捕まった人は確かにちょっと悪い

でも、逮捕するレベルのものではない

Apple審査やgoogleのブラウザ改善など、民間レベルで対処されるもの

これで逮捕ならエンジニアの萎縮と技術衰退の恐れあり

そして警察や裁判所への嘲笑をまねく恐れあり

<意見書>

裁判関係者の皆さま、お忙しいところに本文をご覧頂き誠に有難うございます。

僕はエンジニア社長としてbravesoft社の経営を行っており、この判決の影響を受けるものです。

bravesoftは創業15年で150名、アプリやWEBサービスの開発を行っております。

これまで開発したものは首相官邸やアメリカ大使館、東京大学やJST、NICTなど公共団体のものからTVerやボケて、ベネッセ様や東京ゲームショウなど民間のものまで1000件以上に及びます。

コインハイブはたしかに技術のよろしくない活用例だとは思いますが、今回の判決で有罪にしてしまうことは大変危険なことです。

「この程度のこと」で逮捕されるとなると、エンジニアとしてはリスクを避けるために多くの開発案件を拒否せざるを得ません。

コインハイブで受ける閲覧者の不都合(PCが重くなる)は軽微な不都合です。そしてPCが重くなるサイトは自然と使われなくなります。

こういった利用者に不都合をあたえる技術はgoogleなどのブラウザ開発者やまとめサイトなどから支持されないために、自然と淘汰される事例をこれまで何度も目撃してきました。

「この程度のこと」というレベルを技術がわからない方に分かりやすく伝えるなら、テレビ番組が広告と明示せずにスポンサーの商品を紹介したら逮捕されるようなものです。どこまでが広告でどこまでが本当なのか?スポンサーのイチオシの服をドラマのヒロインが着ることは悪なのか? その線引は曖昧故に、今回有罪になると今後我々エンジニアは多くの開発を拒否せざるを得なくなるのです。良くない番組は自然と淘汰されます。そこに警察や司法は必要ありません。

視聴の対価を払う方法には、広告、課金、そして今回のようにPCリソースの提供などいろいろあります。それぞれの対価を払う方法でサイトを見ている時点で、「意図」に沿っていると言えると思います。嫌ならもう2度と見なければいいだけです。

それを明示するのか明示せずに行うのか、もちろん明示したほうが良いと思いますが明示しなくても「逮捕されるほどの不正」では無いと思います。

創業当初より「技術で社会に貢献する」という思いで真摯に開発に励んでまいりました。社会にもそういう真っ当なエンジニアが大多数で、悪い人が長期的に反映することもありません。

技術革新は常に「なんか怪しい、大丈夫かこれ?」から始まります。

例えば、

LINEは勝手に友達の電話番号を友達候補リストに表示しています。
スマートニュースは勝手に新聞の記事をコピーし表示しています。

どちらも僕は「これ大丈夫か?」と思いましたが、一般に受け入れられて、多くの人を幸福にしました。大丈夫かどうかは民間が自主的に判定し、ダメなものは民間が排除するメカニズムがあります。今回程度の行為が法的にダメという判例ができれば、多くの新規プロジェクトがリスクとなり開発そのものが開始されません。

いま、今後の日本の技術革新に大きな足枷となる判例が生まれることを恐れています。

そしてもっと恐れるべきは、警察や裁判所へのエンジニアや国民の不信感や嘲笑です。

日本人はなんだかんだいって警察や裁判所を信頼しています。

逮捕される人は悪い人だと信じています。

刑罰そのものよりも「逮捕」という風評により生きづらくなるのを恐れています。

この警察や裁判所への信頼・逮捕への恐怖こそが世界No.1の平和的国家を実現しています。

僕が「コインハイブで悪い人が捕まったんだな」と当初感じたのは警察を信用してたからです。

この判決により「逮捕された人はそんなに悪くないかもね」「むしろ勇者でカッコイイじゃん」

に人々の意識が変わることを恐れています。

我々が警察や裁判所を信頼しているように、ぜひ皆さまも我々民間を信頼してください。

コインハイブはよろしくない技術だと思いますが、法的には無罪で良いと思います。

少しでも参考になれば幸いです。
よろしくお願いいたします。

最強エンジニア社長 & つよつよチャンネル。始動 #22

23歳でbravesoftを起業したその日から、ひたすら営業と開発に走り回ってきた。

「最強のものづくり集団となり、挑戦が溢れる新時代を創る」

ただそれだけのために上を目指し続け、たぶん毎月300時間以上の稼働を下回ったことはない。

気づけばもう15年が経過して、会社もそれなりの規模になってきた。

営業も開発も信頼できる仲間が集まり、社長らしい仕事をやるフェーズ。

それは、広報。

bravesoftを広く知ってもらい、参加したい!取引したい!という同士を集める活動だ。

もっと言えば、これは会社を作った目的でもあるけど、エンジニアの声を社会に届けたい。

日本におけるエンジニアの立ち位置は異常だ。

エンジニアはどこかで、一般社会に距離を置かれている。

エンジニアがたくさんいる会社。受託・SI・SESの会社も、どこかで距離を置かれている。あるいは下に見られている。地味な存在で、言ったことをやってくれるだけの存在。受注までは何でも言うことを聞き、受注後は何ら融通が利かない(まあこれは他の業界にもあるか笑)、あまり友達になれない人たち。。

エンジニアはスーツを着てブツブツ言いながらスケジュールの帳尻を合わせるキツイ仕事。

日本ではエンジニアはそんな存在だ。

海外では違う。AppleもAmazonもfacebookもGoogleも創業者はみんなエンジニアだった。世界中のエンジニア達が社会を先頭で動かしているのだ。

もう皆忘れてしまったようだけど、日本はかつて技術立国だった。戦後の焼け野原から、たった40年で世界一になるまでに引き上げたのはソニーホンダを始めとする技術者の情熱だった。

エンジニアを最新ガジェットとRedBullを与えられて喜んでるだけの「ただのいいヤツ」で終わせてはいけない。社会のあり方そのものをアップデートする主体的役割を果たしていくべきだ。そして日本を挑戦できる国に再び変えていくべきだ。

愚痴を言ってても仕方ない。自分がやるしかない。

1.卑屈になっているエンジニアに誇りを持たせたい。
2.少しでもエンジニアの声を集めて社会に届けたい。

そう、僕の仕事はここまで来たらもはや「すべてのエンジニアの広報」なのだ笑。

まずは2020年2月からラジオ日本様で目指せ!ハッカーRADIOというラジオ番組を始動。有名なエンジニアにインタビューをしながらエンジニアの声を業界内外に届けることを始めた。かみまくりのぎこちない進行でお恥ずかしいが、とにかく始めた。どこまで行けるか全く分からないが、とにかく動き出した。

そして最近、次の一手として最強エンジニア社長というキャッチコピーでTwitterを始めた笑。

アカウントだけなら取得したのは結構前の2008年。あのころのTwitterは荒れていた。

実はかつて、Twitterで炎上騒動を起こしたことがある。

虚構新聞というアプリでただAppleの仕様通りに個人IDを使っていたことが、プライバシーを不正に取得していると言われてしまったのだ。

(ググってみたら当時に書いた謝罪文を発見。30歳になりたての自分、結構頑張ってる笑)

そんなこともありすっかりTwitter離れしていたが、この際、Twitterも始めてみることにした。

そして最近は空前のYoutubeブームである。芸人引退をかけてジャージ姿でYoutuberになったカジサックさんには多くの人が勇気づけられた。

ということで、さらにさらにYoutuberも始めることにした。

YoutubeもTwitterもまだまだ素人レベルなので、元ブレイバー(bravesoftでは元社員をこう呼ぶ)でテンガマンという全世界が呆れる訳のわからない動画を作った孫さんにも手伝ってもらうことにした。(それでも200万再生!)

<とある土曜日の朝方>

孫さんから電話「どんなチャンネルにするか打ち合わせしましょう!」

菅澤「オッケーじゃあとりあえずドライブ行こうか」

孫さん「行きましょう!」

運転しながら孫さん「どこ行きます?」

菅澤「うーん伊豆とか?」

孫さん「そっち方面渋滞みたいす。川越あたりに落ち着きません?」

菅澤「え〜そしたら富士山で手を打たない?」

孫さん「・・まあとりあえず行ってみますか。そういえば最近ドローン買ったんですよ」

菅澤「いいね!そしたら動画取ろうよ」

・・結局、1泊2日でドライブしながらの打ち合わせ10時間&富士山絶景で撮影10時間というバタバタしつつも楽しいYoutube合宿となったのだった。

帰りの孫さん「いい絵が取れましたね〜。ところでチャンネル名どうします?」

菅澤「うーん、最強を目指すし、最近つよつよって言葉楽しくない?」

孫さん「じゃあそれにしましょう!」

チャンネル名は「つよつよちゃんねる」に決定。アイコンはもちろん富士山だ。

こういう感じで、生涯をかけた楽しい挑戦のドライブを、15年間続けているのです。

つよつよちゃんねるの登録はこちらっ!

20代で人生は変わる。30代では変わらない #21

いきなり手紙が届いた。三宅島の小学校の松本くんからだ。
エンジニアになりたくて、エンジニアの人に会ってみたいとのこと。

こういうのにbravesoftは弱い。半ば強制的に社長の僕が出かけることになった。

その授業はドリームプロジェクトといって、小学生が自分で憧れの人を呼んで授業をしてもらうというひとりの熱血先生の情熱から始まった素敵なプロジェクトだ。

三宅島では先生と飲みながら島の生活を聞いたり、小学生とボケてで盛り上がったり、歌を歌ってもらったりと楽しく過ごした。その時スピーチした内容が、全国の小学生にも伝わればいいなと思ったので、ここにアップする。

———

ここに来る前、みんなは小学生に話が通じるの?なんて心配してましたが、僕は小学6年はもう大人だと思ってるので、大人が大人に向けて大人の話をします。

皆さんはきっと不安だと思います。島に生まれて将来都会でやっていけるのか?とかね。

でも、僕はむしろラッキーだと思います。この島には大自然があり温かい人間社会があります。

ところで、iPhoneを作ったのは誰か知ってますか? そうジョブズです。

ジョブズは子供にiPhoneを持たせませんでした。自然や人間から学ぶのが大事だからです。

ちなみに、子供はなぜ遊ぶのか知ってますか?なんで遊びたくなるようにできてるのか?

それは学ぶためです。

子供は遊ぶことで、たとえば水の怖さだったり、火の起こし方だったり、人を傷付ける愚かさだったり、いろんなことを学びます。

なので、思いっきり毎日遊んでください。家でゲームをやるのもいいけど、ほどほどにして自然の中で思いっきり遊んでください。

都会の子は自然がないのでゲームばっかりやってます。でも、ゲームをいくらやっても新しいゲームは創れません。カレーばっかり食べてもカレーやお米や野菜が創れるようにはなりません。

答えは自然のなかにあります。みなさんは答えに囲まれて生きています。チャンスです。ゲームもカレーも自分で作ったほうが楽しいよ。

皆さんは今日、僕の事を見てこう思ったと思います。「楽しそうな人だな」と。

あまりこういう事を言う大人は普段いないかもしれないけど、僕は毎日超楽しいです。つらいこともたくさんあるけどそれも含めて楽しいです。

だから、皆さんにも毎日楽しいと思って生きる人になってほしいです。

じゃあ今から何をしたらいいか? 夢を持ったほうがいいか? どうすれば成功できるか?

さっきそういった質問もあったけど、

なにも考えなくていいです。

20代で社会に出てからが本番です。それまでは適当に勉強とかゲームとか遊んでてください。

そして、20代は本当に重要です。

楽しく生きるためには、20代で何かに打ち込んだり、つくったり、たくさんの挑戦をして、たしかな自分を手に入れる必要があります。

20代からの社会の広さを思えば、10代の頃の悩みなんてなんでもないです。

例えば君の友達で今まで1回も家から出たことなくてウジウジ悩んでる人がいたら、

「とりあえず外に出て学校こいよ」

って思うよね?

社会に出るのはそれ以上のことです。だから考えるのは社会に出てからでいいです。

それで、もし20代でなんにもせずダラダラ過ごしてしまった場合、

30代になってから何かをしようとしても無理です。30代になったら人はあんまり変わりません。

だから20代のときに、本気を出してください。それまでの10代は何もしなくていいです。

今日、一番伝えたかったのはこのことです。

さいごに、1つだけ人生が良くなるコツを教えるとしたら、「手を挙げる」ことです。

これなら今日からでもできるよね?

今回は松本くんが手を挙げたことで僕はここまできました。この場を一番楽しんでいるのは松本くんであり、そして大きな自信にもなり、少しだけ人生が変わったと思います。

まっさきに手を挙げる人には良いことしか起こりません。

来月皆さんは小学校を卒業するらしいですね。

ウジウジしてなんにもしない人生を卒業して、少しでいいので勇気を出して、

手を挙げる人になってください。

それでは終わります。

がんばってね!

——-

東京に戻ったら、たくさんの手紙が届いた。そのうちのアツい一通をシェアして終わろう。

きたぞ未来!世界中で大流行の電動キックボード(スクーター)を体験レビュー&解説 #20

10年前から現代にタイムスリップしたなら、全員がスマホを見てる景色に驚愕するだろう。

ごくたまに、1つのイノベーションが街の景色を一変させることがある。

2020年1月。3年ぶりにロサンゼルスに来たらところ、まさに街の景色が一変していた。

電動キックボードだ。街中に電動キックボードが放置されているのだ。

さっそく試したところ、これがまあ快適!

なんと3日間で18回も乗ってしまった。これはきっと近い将来に世界中の景色を変えていくはず。モビリティ革命といえば自動運転やドローンも華やかでおもしろいが、身近で起きている確かなイノベーションにもしっかり注目しておきたい。

2年前からブームになってるようですでに日本語の記事もいろいろとあるが、普段からサービス開発をしている現場の目線から、最強にわかりやすくレビューしよう。

電動キックボードって?

・電気で動くキックボード
・街中に雑に置かれている
・車道の自転車レーンを走る
・どこで乗り捨ててもOK
・時速25km (かなり速い自転車)
・300円ぐらい(乗車100円+20円/分とか)
・スマホアプリから利用
・スタッフが巡回して充電
・運転は自転車よりカンタン
・自動車免許必要(日本のでOK,免許いる?)

きっかけはGoogleマップ

最初に知ったきっかけはGoogleマップ。アプリの言う通りにぼーっと歩いていたらいきなり電動キックボード出現&乗り換えるように指示された!笑

LAは地下鉄が少なくて、電動キックボードを使ったほうが速いシーンが多々ある。

どうやって使うの?

①アプリDL&初期登録

主に3つの会社(Lyft,Lime,BIRD)から選べる。

今回はLyftを選択した。Lyftアプリがあれば新規DL不要。
(LyftはUBERと同様の配車サービスで、米国ではこの2つが競い合っている)

SMSやクレカ+免許証(日本の免許でOK)のアップロードなどよくある流れで2分で完了。

キックボードに乗る

近くのキックボードの予約ボタンを押すか、QRをスキャンすれば即利用開始。はやい!

キックボードを降りる

降りるボタンを押して、キックボードの写真を撮影で終了。カンタン!

どんなときに便利?

数キロレベルの移動に便利。

たとえば、

渋谷→原宿
都庁→歌舞伎町
上野→秋葉原

割と近いのに、徒歩や電車だと30分ぐらいかかってしまう移動が10分足らずで到着できる。
特に重い荷物を持ってる観光客や買い物客はとっても助かる。


バッグが重かった(20kgぐらい)ので助かった

イケてるところ

・乗ってるだけで楽しい
・見た目もクール
・こがなくていいから疲れない
・自転車より転びにくい
・押して歩くのも自転車より楽
・立って乗るので乗降がスムーズ

電動キックボードを知ってしまうと、わざわざ自転車に座って「こぐ」というのが面倒に思えてしまう。さらに重心が高いためぐらつくて危険。そして必死にこぐ姿がスマートではないと思う人もいそうだ。

港区のシェアサイクルも想ったより伸び悩んでいる??

他の移動手段との比較

他の移動手段と比較してみた。

「タクシーみたいに疲れず自由に動け」て「まあまあ安い」というポジションを狙っている。

もちろん近くにキックボードが置いてないとダメだが、実際は観光客が行くようなところ(東京でいう山手線周辺)では大丈夫だった。

どんな会社がやってるの?

主なプレイヤーは4社。先行2社と、類似事業の大手2社(UBER,Lyft)が後発。

戦いはまだ始まったばかり。各地で大激戦が繰り広げられている。LAにも各社のキックボードでごった返していた。

個人的にオススメはLyft。また勝手な予想だが、最後に勝つのはLyftじゃないかと思ってる。

理由1:割とシンプルなモデルで差別化が起きにくく、規模の経済で大手が勝ちやすそう
理由2:Uberはセクハラ問題とかでバタバタで印象も悪い。Lyftは追い上げており勢いある
理由3:Lyftのセンス良いクリエイティブがキックボード的なファッション性と合いそう

実際、LAではLyftのキックボードが一番多かったように思う。

夏に大ヒット→急落!?

つい最近の2020年1月、電動キックボードにとって不吉なニュースが流れた。

電動キックボードのLimeが12都市から撤退し約100人を解雇

え?? 絶好調じゃなかったの??

考察のため、、Second Measure社による各社の売上推計を見てみる。

このグラフを見る限り、2つの理由が推察できる。

①冬は寒いのであんまり乗らない
②各社の参戦で観光客の争奪戦が激化

去年の各社経営会議はこんな感じかも・・

8月「すごい伸びてる!この市場は凄いことになるぞ。投資倍増、大量採用だ!」
12月「すごい落ちてる!寒いとこんなに落ちるの?? 人を減らせ〜!」

新興市場の行く末は誰にもわからない。経営はジェットコースターだ。

市場としては今はまだ黎明期で

観光客
クールに生きたい若者

のニーズを熱狂的に支持されはじめてる。

ただ、それだけだとそんなに大きな市場でもない。(小さくもないけど)

より大きく狙うには地元の普通の人達に使ってもらう必要があるが、iPhone普及に10年もかかったように、習慣はそんなに早くは変わらない。しかし着実に変わっていくとも思うので、しっかりマーケ&バージョンアップを続けたところが勝者になるのだろう。

日本でも流行る?

間違いなく流行る。港区は混雑してて道も狭いので、まずは吉祥寺とか、横浜あたりがいいかも。また地方都市では確実に需要がある(特に観光客)。そして港区でもダメかというと、自転車はバンバン走ってたりするので、港区でもきっとイケるはず。(ただ地下鉄やバスが充実してるのでそこまで強い需要でもない)

じゃあなぜ今の日本に無いのか。規制のためだ。

日本では電動キックボードが「原付」とみなされ、ウィンカーやヘルメットが無いからNGと。。

「原動機(エンジン)が付いた自転車」と書いて原付。

でもこれ、自転車に見える??

電動自転車はOKだけど、
電動キックボードはNG

電動キックボードは公共の利益にもなると思う。

・自転車よりも安全
・インバウンドや観光客に優しい
・自動車やバスを減らし環境に優しい
・高齢者に優しい
・高齢者の自動車事故を減らせる
・渋滞を減らせる

実は原付バイク普及の立役者はかの本田宗一郎。戦後で物資がないなか、自転車に外付けエンジンをつけて、やっつけバイクをつくったのだった。そしてそれが本格的なバイクに進化してHONDAが世界中を席巻。それはまだ日本全体がベンチャーだった時代の話。自転車にエンジンつけちゃおうぜぐらいの若いノリがあった時代。そのチャレンジスピリッツこそが今の日本に足りないものだ・・。

ということで、これを見るかも知れない偉い方・・。

一緒に動きましょう!

体験してみよう!

どうしても国内で電動スクーターにのりたい場合、挑戦する都市「福岡市」がちょくちょく実証実験をやっているので随時チェックしてみよう。

でもやっぱり、ただ乗るだけでなく、日常がいかに変わるかを実際に体験してほしいところ。

その場合には海外に行くしかない。

そこで2020年2月時点で電動キックボード(Lime)に乗れる国を列記した。(最新情報はこちらから)

アメリカ
アルゼンチン
オーストラリア
オーストリア
ベルギー
ブラジル
ブルガリア
カナダ
チリ
コロンビア
チェコ
デンマーク
フィンランド
フランス
ドイツ
ギリシャ
ハンガリー
イスラエル
イタリア
メキシコ
ニュージーランド
ノルウェイ
ポーランド
ポルトガル
ルーマニア
シンガポール
韓国
スペイン
スウェーデン
スイス
UAE
イギリス
ウルグアイ

10年後、街の移動手段が自動運転バスと電動キックボードだけになった未来を想像してみよう。
もし想像がつかないのなら、まずはロサンゼルスを訪れてみるべし。

世界中が感染した時、最後に生き残るのは日本人 #19

武漢発の新型コロナウィルスに世界が注目している。

中国に子会社を持つ我が社にとっては他人事ではなく、実際に社員の知人が感染したなどといった声も聞く。沈静化の目処が立ってない現状では細心の注意を払い準備するのは当然のこと。

こんなときふと思い出すのは2009年の新型インフルエンザの大流行。「パンデミック」という言葉が一躍有名になり、日本中がパニックになった。

連日のようにどこどこで発症、死亡者が出たといったニュースで溢れ、山手線の中でもマスクをしていない人はいないほど。

あれは2009年のGWのあたりで、発症源は北米だった。

・・・そしてちょうどその時、僕は友達と人生初の渡米を予定していたのだった。

前回のグランドキャニオンで死にかけた話しかり、どうもUSAにいくと何かが起きる。

もちろん、キャンセルすることも考えたが、まだまだ若くてお金も地位もない20代。もったいないしやっぱり行こうという話となり、不安を覚えながらも、成田空港へ向かった。

成田空港に人影は少なく、報道カメラが旅行者にインタビューしたりしていた。我々2人はしっかりとマスクを買い込み、戦地にでも向かうようにニューヨークへ旅立った。

ところが・・

ニューヨークに着いてみたら、マスクをしている人が全然いないのだ。

それどころかあろうことか・・

なんと我々がマスクをして歩いていると、道行く人が「おいチキン!」とか「ゴホゴホ」とか言ってからかってくるのだった。どうやら向こうではマスクをするというのはよっぽどのことらしく(あるいは逆に不安にさせるんじゃねーよバカ!みたいな感じか)変に目立ってしまったのだ。今の東京で、ガスマスクをして歩いたら同じ目に合うかもしれない。

何度かの屈辱のあと、我々はマスクを投げ捨てた。

それから5日ぐらいニューヨークをブラブラしただろうか。インフルのことはすっかり忘れマンハッタンを満喫し、あっという間に成田に帰ってきた。そうするとまた現実が出迎える。戦場は日本だった。輪をかけて日本中はマスクだらけ。文化の違いとはこういうことかと実感した。

テレビでは毎日のようにどこどこでインフルで死亡者がというニュースで大騒ぎ。今と違い誰もがテレビでニュースを見てた。発症者がでた高校に非難が集まり、マスクは飛ぶように売れた。

エンジニアの仕事は何でも疑ってみることだ。

僕は国内で1年にインフルで死亡する数を調べてみた。毎年だいたい1万人もインフルで死亡してしまうらしい。そうすると毎日のように数名は死者がでる。ニュースで言われているインフル死亡者も1日数名。あれ、いつもとペースそんな変わってない・・?

最終的に、新型インフルによって国内で死亡したのは200人だった。

1ヶ月後、厚労省は「新型インフルの死亡リスクはいつものインフルとそう変わらない」と発表した。つまりいつも流行ってるようなインフルに名前がついただけで、あそこまで騒ぐのはまさに取り越し苦労というやつだったのだ。

・・ああ!これだから日本人は心配性で数え切れないほどの保険に入り、無数の鍵を持ち歩き、占いに大金を費やし、大量のトイレットペーパーを買い占めるのか。

心配だらけでいっこうにハッピーになれないんだ!

と一瞬思ったが、ちょっと考えて思い直した。

「なんだかんだで最後まで生き残るのは日本人だな」

なにしろあのときはどうなるかわからなかったのだ。新型インフルが本当にやばいウィルスだったとしたら、ニューヨークの彼らは全滅だろう。結果論でいえば大丈夫だっただけで、本当にヤバいやつだったら、日本人だけが生き残った可能性もあっただろう。

検証までに、wikipediaによる2009年新型インフルの国別発症数の推移データをもとに100万人あたりの感染者数のグラフをつくってみた。(あくまで概算)

そう、日本人は圧倒的に感染率が低く、世界最強のウィルス防御力を誇っているのだ。

この争いばかりの世界が仲良くなれるとしたら、宇宙人が攻めてきたときだ。共通の敵がいると人は仲良くできる。新型ウィルスはまさに宇宙人のような存在。得体が知れない人類滅亡の危機を前に世界は一つになれるかもしれない。昨日も日本が中国に大量のマスクを送って中国人が15万いいね!をつけた、なんてニュースが流れていた。

日本人という民族は常に災害の危機にさらされてきた。

知ってるだろうか?

この地球上で、全人類の1.2%しか日本に住んでいないのに、大地震の20%は日本で発生する。そのうえ台風も洪水も大雪も飢餓も火事も頻繁に襲ってくる。もともと地球でイチバン危ない土地で、日本人は助け合いながら協調して乗り越えてきたのだ。1500年前に初めて作られた法律の一番最初には「和をもって尊しとなす」と書かれたのだ。

政治の「治」という字は川に台をつくると書く。助け合って洪水を防ぐところから共同体が生まれ、国となり、人類は栄えてきた。新グローバル時代に災害を機としてとらえ人類が和をもって助け合うべくリーダーシップを取るべき国は日本なのかもしれない。

政府だけの問題じゃない。

政府が電車を止めたり外出を禁止したり、入国を拒否するのもたしかに少しは有効かもしれない。しかし誰とも合わずに生きていける人はいない。

それ以上に一人ひとりの意識を変え、行動を変え、習慣化させることの効果は絶大だ。

日本が世界に教えられることはたくさんありそうだ。

・手を洗いましょう
・アルコールで消毒しましょう
・マスクをしましょう
・水回りキレイにしましょう
・料理にはしっかり火を通そう
・風邪気味なら無理しないで
・家では靴を脱ごうよ

我々からすれば当たり前の話ばかりだが、海外を旅する人は世界がいかに不潔か知っている。パリは犬のフンでまみれ、ニューヨークの地下鉄は下水道のようだ。

日本には世界一キレイで健康的で、助け合える人々が過ごしている。

政府に文句をつけて仕事をしたような気持ちになることもできるけど、あなたにできることはないだろうか?

【おまけ】グランドキャニオンで意思決定トレーニング #18

「人生とは旅であり、旅とは人生である」

これは中田英寿選手引退メッセージのタイトルで、いつも心の底に流れている言葉。

この記事はグランドキャニオンの1500m崖下で死にかけた話のおまけだ。

前回は、わかりやすく伝えるためにストーリ調で書いたが、その裏で普段から実践しているのが意思決定トレーニングだ。記事の内容はもちろんすべて事実だけど、実際のところ不用意なミスで危険な目にあったというよりは、わざわざ自分を追い込んでいたのだ。

人生も旅も、本質は先の見えない冒険だ。予想外のことも起きるし、ギリギリの意思決定が求められる。なので僕は、旅に学び、成長につなげるべく意思決定トレーニングを実践している

意思決定トレーニングについて

旅の最中はわざとこのようなハプニングが起きる環境に身を置くようにしている。いい感じにバスが来たら乗ってしまうし、良さそうな川を見つけたら、とりあえず向かってしまうのだ。ハプニングに出会うために。

一人旅においては、情報は足りなくて、協力者もいない。基本的に無力だ。その中でどのように生き抜くか、どうすれば目的を達成するか、極限な状況に追い込まれても冷静で正しい意思決定をできる判断力を育てていくのだ。

人生や経営は旅以上に未知な冒険である。進学・転職・結婚、起業や重要案件など、意思決定を前には誰もが不安で、情報やリソースは常に不足している。

そんな中、いかにベストな意思決定をするか?

旅はそんな未知の状況で意思決定し、結果を反省し成長に活かせる格好のトレーニング場だ。

川を目指すという意思決定

今回、大きな分岐点は、「⑤聖なる川」でルートの間違いに気づくも、川を目指すという意思決定をしたときだ。その前もバスに飛び乗ってみたりはしているが、この時まではただの気楽な観光だった。限られた情報のなか、それでも川を見に行きたい。そう思う人は1%もいないかもしれないが、子供のような本能的な気持ちに素直に乗っかる人もいる。僕みたいな人だ。そしてトレーニングのためでもある。

ある種の変態かもしれないが、挑戦に成功する人は、きっと川を目指すタイプの人だ。

僕の経験でいうなら、

①大学を卒業時、不安もあったが就職せず起業の道へ。
→地獄を見たが、結果的に15年成長を続けている。

②iPhoneが不発だと言われる中、未来を感じていち早く開発に投資。
→必死に実績を重ね「アプリ開発実績」でgoogle検索日本一位を獲った。

③オフィスが手狭になり、倍以上で月額350万の新オフィスに思い切って移転。
→経営危機に陥るも、3年後にはそのオフィスも手狭に。

などの意思決定をしてきた。

結果論で見ると楽観的ノリで飛び込んだだけと見えるが、現実は人生を賭けて多額の借金を負い先の見えない不安で夜も寝れない中での、ギリギリの意思決定ばかりだ。

孫正義は、「7割の成功率が予見できれば事業はやるべき。5割では低すぎ、9割では高すぎる」といっている。ビジネスの世界では、情報が完全に揃って状況が見通せるようになってからでは遅すぎる。イケるかも、面白そうでいち早く飛び込んだ人達が、その場その場でなんとか乗り越えいち早く成功し、皆が気づいた頃にはすでに次の挑戦に向かっていくのだ。

ふらっと立ち寄ったグランドキャニオンでは、現地の人に比べて圧倒的に情報量が限られていた。情報が限られており、リスクがあるなかで、ギリギリの意思決定を下すという経験は、なかなか普段から経験できない。情報がないグランドキャニオンで目標を達成するためには、質とスピードを兼ね備えた直感的な意思決定が必要になる。

冷静と情熱のあいだ

勇気と無謀は違う。ただノリだけでどこにでも飛び込んでいく人はすぐに命を落とす。

実際に⑤で川を目指すのを決めるときには

・地図で見る限り、帰ってこれる距離
・自分の登山実績からも大丈夫という判断
・地図を見る限り観光用に整備されていそうな道
・周りにちらほらと登山客もいる
・他の人が重装備でいける道なら軽装の自分なら大丈夫だろう

ということを判断材料に意思決定をした。

もし今回のケースで命を落とす人がいるとしたら、

「⑥迫る危機」で3時間下ったあたりで怖くなり、引き返して途中で力尽きて夜になる
「⑦登山口での葛藤」で勢いに任せて一気に登るが水不足で力尽きて夜になる

のどちらかだ。不安にかられて冷静さを失ったときが一番意思決定を誤る。

稲盛和夫は「楽観的に構想し、悲観的に計画し、楽観的に実行する」と言う。

川を目指すところは楽観的に構想し、実行に移すが、その節目節目で、悲観的な計画を挟むことが重要で、⑥や⑦登山口の葛藤においても、常にその時点までに得た情報をフル動員して計画を悲観的に冷静にアップデートし続け、必ず成功できる道を選ぶのだ。

また、最悪は野宿で夜を明かしたり、救助隊のお世話になることなど、ワーストケースを受け入れる悲観的な「覚悟」も決めておく必要がある。(これまで20回以上はこのようなトレーニングをして一度も救助隊やそれに類する迷惑をかけたことは無いが、最悪の場合は躊躇せずに頼ることも大事なことだと思う)

そして引き受けられるリスクは負うが、それ以上に危険なことはもちろん避ける。

たとえば、

・エベレストにノープランで昇ったりしない
・太平洋を1人カヌーで渡ったりはしない
・デモ渦中の香港は見に行くがデモには近づかない
・スラム街を歩くが観光客風じゃなくボロボロの格好で。

などだ。感覚値になるが、死亡リスクが1%以上あることには絶対に近づかない。

また、いきなり無謀なことに挑戦するのでなく、これまでに何十回とこの種の体験を繰り返し、少しずつ難易度を上げていってるので、安易に無謀な挑戦をしているわけではない。

検証と反省

「人間は失敗する権利をもっている。
 しかし失敗には反省という義務がついてくる」

と本田宗一郎は言う。失敗から学びに活かすことが成長である。PDCAともいう。

自分の場合、⑦の時点でわからなかった情報は、

このルートは登山に何時間かかるのか?
この登山道の途中に水はあるのか?
一杯も水を飲まずに1500mの登山は可能か?

の3点である。

それを検証するため、翌日の登山では、水は携帯はするが一杯も飲まず、そして1度も休憩せずに、自分の体力を検証した。結果として、飲まず休まずで4時間10分で登り切ることができた。登ってみたら帰りのルートはかなりのeasyコースで水が飲める休憩所も3箇所あり、歩きやすかった。行きのルートがあまりにもhardコースで危険だったのだ。

つまり、あの時に日帰りを選択し、一気に登ったとしても成功できた可能性はたかい。しかしそれは結果論で、あの時点の情報量の中では、無謀と思える意思決定するべきではなかった。

そして、「登るのもありだった」という結果をインプットすることで、直感力は鍛えられる。

先送りは最悪の意思決定

今回、いちばん緊張したのは⑦の昇るかどうかを意思決定するタイミングだ。何しろ山の夜は早い。まだ昼過ぎだったとしても、昇るなら一刻も早く意思決定しないといけないのだ。

誰もが意思決定を先送りしたがる。先送りすれば情報が増え成功率が上がるし、決定には責任が伴い怖いからだ。今決めたほうがいいのに、「来週の会議の反応を見てから・・」「少し様子を見ながら・・」などついつい易きに流れるように先送りしてしまう。

しかし、今決めないというのも1つの意思決定なのだ。例えばうちの会社は月間1億規模の経費を使っている。ということは1日300万だ。1日意思決定を遅らせることは、300万円を無駄に捨てるようなものだ。先送りせず今決めるべきだ。先送りは最悪の意思決定だ。

「あなたはいま意思決定しますか? する or 先送りする」

というプッシュ通知が1分おきに届くとしたら、先送りする人はだいぶ減るだろう。意思決定が早い人にはこの脳内通知が毎秒届いており、一分一秒でも早く意思決定をしている。

リスクと付き合う人生を

今回の行動はほとんどの日本人に共感されないだろう。

「なるべく危険な行動は避け、周囲に迷惑をかけないよう、安全を第一に」

という正論に従うことで、多くの人は幾百の素晴らしい体験をあきらめている。登山家や冒険家が遭難し救助されるときには非難罵声が飛び交う。

それでも、人生は一度きり。

飛行機に乗らなければ墜落することはないかもしれないけど飛行機で死ぬリスクはたった10000000分の1。今年、あなたにガンが発覚するリスクは100分の1。リスクは生きている限り身の回りに溢れている。

例えばあなたが旅先で突然津波に襲われ、過酷な自然環境と見知らぬ人間社会の中に置かれるリスクも意外とありえる。そのときに備えるには、リスクを避けるのではなく、リスクと付き合いトラブルの中を生き抜く力を付けておきたい。

リスクと付き合うことで、素晴らしい体験や、成長が得られるのであれば、受け止められるリスクは積極的に引き受けて、楽観的に挑戦していこうというのが僕の考えだ。

旅も人生も、踏み出せば道があり、仲間ができ、感動がある。

一歩踏み出す勇気を、僕は旅のなかに育てている。

グランドキャニオンの1500m崖下で死にかけた話 #17

人間はいつの間にか生まれてきて、なんとなく生きてる。
だから死ぬときもきっと、思ったよりあっけない。

①誰もいない駐車場

社長業がいそがしい僕にとって、年末年始は旅に出る貴重なタイミングだ。毎年恒例、ノープランでフラッと海外に出る。以前書いたアジャイル旅行のスタイルで、バックパッカーよろしくその場その場で気ままに旅を描いていく。

自由気ままで学びの旅。幾千のタスクに追われる日常から離れ、ひとり異世界へ。日常が故郷で、これが旅ともいえるし、あるいは夢を追いかける日常こそが旅で、ありのままに自分と向きあう自由なこの時間こそが、故郷なのかもしれない。

2020年の年末年始は香港・深セン・マカオ、アメリカを旅していた。大晦日の夕日をアメリカ西海岸のサンタモニカで見送ったあと、キャンプカーでドライブに出た。そして3日後には1000km離れたグランドキャニオンで氷点下の深夜、僕は誰もいない駐車場のキャンプカーに一人、透きとおった星空の下にいて、

「明日はグランドキャニオンの奥へ行く。生きて帰ってこれればまた会おう」なんて全社員へ冗談交じりの新年挨拶メールを送ったのだった。

まさか本当に帰れなくなるとは夢に思わずに。

②朝日とともに

目が覚めたのは夜明け前の深夜5時ぐらいだった。外はすっきりと晴れた美しい星空の夜。都市から数百km以上離れ、インディアンも住まなくなった砂漠と草原と崖だけの地は、宇宙と直接につながっていて、星がすぐ近くに感じられた。

あと1時間で夜が明ける。

他にやることもないし、せっかくだからグランドキャニオンに昇る朝日を見に行ってみるか。

撮影用のスマホだけを持ち、近所のコンビニに出かけるような、セーターにジャージの軽装で、歩いて五分の展望台へ向かう。一番人気のその展望台には、まだ5人ぐらいしかいない。

夜明けまでの1時間、朝日はゆっくりと厳かに、今日も目の前に登ってくる。グランドキャニオンに昇る朝日は、すべての孤独や不安を優しく打ち消すように明るくて、暖かくて、大きかった。今日も良い1日になりそうだ。

③バスには乗ってしまえ

朝日が昇りきる頃には、展望台には30名ほどの人で賑わっていた。ああなんだかうるさくなってきたし、いったんキャンプカーに戻るか。

すっかり明るくなった雪道を早足でキャンプカーに戻りかけていたそのときだった。

バスが停まっている。

あれはもしかして例の展望台へのシャトルバスじゃないか?まさに今日行こうと思っていた展望台。自家用車は進入不可でシャトルバスのみが許されていて、少しだけ奥へ進んだ場所にある。

その展望台からは、グランドキャニオンの安全なところを1,2時間で散策できるちょっとした散歩コースが整備されているらしい。もともとそのコースをのんびり歩きながらグランドキャニオンを身近に感じてみるのが今日のざっくりとしたプランだった。

暇そうにタバコを吸っている運転手のおばさんに聞いてみると、エクザクトリー。そのバスの始発だった。まだ朝も早く、他に誰もいない。

「バスが来たら、乗ってしまえ。」

それが旅の基本スタイルだ。マカオでも、ニューヨークでも、エジプトでも、とにかくいい感じにバスが来たら、乗ってみることにしている。google mapがどこでも使える便利な現代では、乗った後に考えればだいたいなんとかなる。

実際の勝敗は、7勝3敗ぐらいで、運が悪いと全然違う方向に連れてかれてしまったりもするが、そこは自由気ままなぶらり旅、その先に面白い体験があったりする。

まして今回は行き先もバッチリ。僕は急いでバスに滑り込み、そしてバスは動き出した。

このときはまだ、いつもどおりの朝が始まっただけで、全然余裕だった。

④ ん?

あっという間に例の展望台についた。なんて良い日だ。前倒しですべてが進んでいる。どこから来たか他の観光客もちらほら歩いている。なんなら、もう少し奥の方までいけちゃうかもな。なんて余裕にひたりながら、見るたびに表情が変わるキャニオンを肴に散歩道を降りていった。

道はちょっと険しかった。山肌を横幅1mぐらいの細道が下っていく。それだけならなんでもないが、時期は冬。道は雪道なのだった。他の人達は鉄製のスパイクとピッケルを持っている。あれ、なんか地球の歩き方に書いてあったテンションと違うな・・。まあでも1,2時間歩けば駐車場に戻れるらしいし、なんてことはない。大丈夫だろう。僕は街歩き用のVANSの靴で、セーターにジャージのコンビニスタイルで、なんどか雪道に足を滑らせヒヤっとしながらも、一目散に下っていくのであった。

なにかがおかしいぞ・・

30分ほど降りたときだろうか、何かがおかしいと感じ始めた。あまりにも急ピッチで下っていくのである。そして展望台にはもっとラフなカッコの人たちがいたが、今、まわりを歩いてるのはスパイクに重いリュックにピッケルのガチ勢のみで、そのなかにあってコンビニスタイルの自分だけが一人でキョロキョロしていた。

⑤聖なる川

いったん足をとめて調査を始める。といっても手元にあるのはiPhoneだけ。そう財布も地図もなんにもない。圏外のiPhone。電池は残り30%。限りある情報を総動員する。

iPhoneは圏外でもgoogleマップは大まかな地図と現在地がわかる。ガイドブックのグランドキャニオンのページもいくつか写真に撮って保存しておいた。

・・・うん、これは間違えちゃったな。

あとで分かったことだが、さっきの展望台に目立たないもう1つの入口があって、そっちこそが気軽な散歩道だった。こちらはガイドブックにものっていない、ガチの登山道、いや下山道。

戻ろうか、今から戻るのか。。30分ほど折り続けた雪道を戻るのはテンションが下がる。ふとgoogleマップに目を落とす、このまま行った先に、一筋の青い線が流れている。ああ、これはきっと聖なる川だ。このグランドキャニオンを数億年かけて削り大きな渓谷を創りあげた川。

ここまできたら聖なる川を、自分の目で見てみたい気もする

聖なる川よ・・僕を呼んでいるのか? 

googleマップで見る限り、往復15kmぐらいか。体力には自信がある方だ。問題なく歩けるだろう。そしてまだ8時すぎ、1日は始まったばかりだ。

周りにはちらほらと登山客が同じルートを目指して歩いている。きっとみんな川を見に行って、そして日帰りするのだろう。

そこに川があるならば、行ってみようじゃないか!

おそらく多くの人が引き返すであろうシチュエーションで、僕は川を目指した。そしてこの意思決定により、命の危険への扉は開かれたのだった。

⑥迫る危機

おかしい、おかしい、おかしい。まだ下るのか。

崖を下り続けてもう3時間が経とうとしていた。googleマップで見る限りには、道は単純な線だった。ただその線は、あまりにも急な下り道だった。あとでわかったことだが、僕はいつのまにか標高にして1500mも下っていたのだった。1500mといえば、富士山登山と同レベルだ。そして普通の登山と違いここは崖。まず下山から始まり、下った後に、登山が待っている。

焦り始めた。

これだけ長く下った道を、今日中に登らなければいけない。足早にペースを上げる。それでもやっぱり、来た道を戻ることはしたくない。自分ならいけるはず、心に言い聞かせる。ちらほらと他の登山家たちとすれ違うのが心の支えだった。ひとりじゃない。大丈夫だ。

予想外だったのは下りの長さだけではない。最大の懸念は水分補給だ。他の登山家たちは当然十分な装備、そして水筒を持っているが、コンビニスタイルの自分は完全に手ぶら。崖の上では氷点下で寒かったが、1500mも下れば気温は10度も上がる。

雲ひとつ無い晴天の中、暑くなってきた。途中から雪も溶けて砂漠のような崖の道。少しずつ大きくなる不安と比例するように足早に崖を下り続けた。その時だった。

川が見えた・・!!!!

あれが聖なる川だ。圧倒的に美しい。こんな砂漠のような崖の中、川は静かに美しく雄大に流れている。まだ眼下300mも下ったところだろうか。それにしても目に見える希望の景色は格別だ。僕は入学式に校門をくぐるような足取りで300mを下りきったのだった。

⑦登山口の葛藤

時刻は12時を過ぎた頃だった。川は美しく透き通り、時間はゆっくり流れている。ボートに乗ったり、釣りをしている人もいる。

天国のような時空の中で、僕はひとり焦っていた。

浅瀬に近づき、何度も何度も手酌で水を飲む。ゆっくりしてはいられない。日没までに下山、じゃなくて登山しないと大変なことになる。考えている暇はない、川沿いに走っていき魔人ブウばりにお腹に水をため、ついでに首に巻いていたタオルも臨時水筒として水に浸した。

日没までに登り切らなければ・・。

日没までに登れなかったら、命の問題になる。氷点下で岩しかない崖の奥、十分な水分も確保できないまま、生きて夜を越せる保証はどこにもないのだ。ガイドブックに書いてあった、「年間数名命を落とす」という警句が頭をよぎる。

googleマップを見ながら登山ルートを考える。帰りは行きとは違う道にしよう。来た道をそのまま帰るのはテンションが上がらないし、駐車場により近いこちらのルートから帰るべきだ。そして意を決し、登山への第一歩を踏み出そうとしたその時、もうひとりの自分がささやいた。

本当に、登れるのか?

一瞬冷静になる。本当に登りきれるだろうか?3時間半下ったということは、上りは5〜6時間はかかるだろう。下りはちらほらいた人々も、上りの道には人影が見えず、ほかの方向へ歩いていった。あっちには何があるんだろう?他の登山ルート?それともキャンプ場?

上り道に水はあるだろうか? なかった場合、このサンシャインの中、6時間も水無しであるき続けることはできるか?もし喉が渇き、動くことができなかった場合、そして誰も通りすがらないなら、力尽きて動けなくなり、本当に命が危なくなる。あるいは、アイスバーンの夜道を一人歩いていたら、足を滑らして崖から落ちる危険性だってある。

焦っていた。

夕暮れは5時。いま12:30から出発して5時間かかるのならギリギリ日没までに間に合うかどうかの瀬戸際。悩んでいる時間はない。いくならすぐにでも出発しなければいけない。どうしよう。いくべきか?どうしよう。

・・ここからたっぷりとスペースを用意するので、ちょっと手を止めて考えてみてほしい。

この状況で、あなたならどうする?


崖上から見たキャニオン。あの谷の奥の奥へ降りていった。


上の方は雪がつもりアイスバーンになっていて危険。


下の方は雪が溶けており、日中は逆に暑い。

⑧ああアメリカン

5分ほど考えただろうか。僕は結論を出した。

諦めよう。

それが結論だった。もちろん命を諦めるのではない。今日中に駐車場に戻ることを諦めたのだ。たとえ数%でも、無視できない確率で命の危険がある以上、冷静になるべきだ。

ちらほら見える他の登山客たちが向かうあの先に、他のルートなのか、キャンプ場なのか、他の選択肢があるに違いない。ここから日没までの5時間、その5時間をつかって、明日を安全に迎えられる方法を確保することに賭けようと決めたのだ。

そうと決めたらすぐに行動。

みんなが進んでいくその川下の道を早足で歩きだすと、キャンプ場らしきものが現れた。そこで手当り次第、スタッフがいそうな建物をいくつか周ったとこ、庭でラジオを聞きながらのんびりしているおじさんを見つけた

「すみません!今日中に帰れなそうなのですが、、僕はどうしたらいいでしょう?」

単刀直入に聞いてみる。

「え??あー大丈夫じゃない。あっちにホテルがあるよ」

耳を疑った。ホテル? こんな文明から10kmもはなれた奥地に?半信半疑で進んでいくと、本当にあった。そこには山小屋があり、その中は宿泊施設兼レストランになっていた。中に入ると大柄でいかにもアメリカンな女性が話しかけてきた。

「Hi! あなた、どこから着たの?東京!!いいねえ私の友だちも東京にいっぱいいるのよ。さあゆっくりしていって。レモネードもコーヒーもビールだってあるわよ」

その時の心境をわかりやすく例えるなら、富士山の樹海を3時間以上も迷子でさまよい途方に暮れていたら、目の前に急に現れたのは「やるき茶屋」

「はい!よろこんで!」・・・圧倒的日常。圧倒的幸福。

もしやすでに命を落とし、幻想を見ているのだろうか?

コーヒーを注文してみる。紙コップ一杯500円という現実的でいやらしい価格設定。どうやら夢ではなさそうだ。お金は持ってないが、こんなときのためにiPhoneケースにクレジットカードをいれてあり、山奥でもクレカは使えたのだ。

キャサリン(仮称)は続ける。

「ねえジョニー!この人は友達のエイジ。帰れなくなっちゃったらしいけど、まだ泊まれるベッドあるわよね?。オッケーエイジ、大丈夫よ。いま準備してるから、コーヒー飲んで待ってなさい。携帯の充電?ないけどたぶんなんとかなるわ。ねえ!お客さんの中で誰か充電器ない?あ、あるみたい!じゃあお願いね」

ああ、アメリカン。自由と友情の国。一時は死を意識した僕は、こんな風にしていともあっさりとに生存を確保したのだった。

本当に助かった。ありがとう楽天VISAカード。(CMみたいになった笑)

⑨聖夜

それは夢のような一夜だった。

宿泊の予約を済ませたら、夕暮れまではまだ時間がある。

明日の経営会議にリモート参加できないことを本社に知らせないと心配するだろう。キャンプ場は電波やネットは一切に通じない。陸の孤島だ。そこでさっきの登山道まで戻る。そこにはベテラン風の登山カップルが休憩していた。

「あなた達もキャンプに止まるんですか?」
「いいえ、私達は上に戻るよ」

もどれるのか・・!

と一瞬おもったが、まあもう予約も済まてしまったし、やっぱり今日は泊まることにしよう。

「すみません、今日はもう上に戻れなくなってしまって、、なので上に戻ったら、代わりにファミリーにメールしてもらえませんか?」
「ああそうなの、もちろんいいわよ!私のスマホにメッセージを書いておいて」

なんて便利&親切なんだろう。手際よくメモアプリにアドレスやメッセージを記入する。

よし、これで皆に心配をかけることも無いだろう。

それからは周囲を探検したり川を眺めたり、宿泊組と交流したりしてのんびり過ごした。夕飯時はにぎやかだった。宿泊者みんなでテーブルを並べてステーキを並べてワインで乾杯。それぞれ自己紹介したり、家族の文句でもりあがったり。僕が一人で来て帰れなくなった話や、LAからキャンプカーできている話をしたら、みんな面白がって讃えてくれた。

最後はキャンプ場スタッフのスピーチが楽しい晩餐会を締めくくる。

「みんな今日はこのキャンプ場に泊まってくれてありがとう。グランドキャニオンには年間600万人が訪れるけど、この川までくるのは6万人しかいないの。その中でも宿泊するのはたったの6千人。あなた達はその6千人の中に入ったのよ。どうぞこのキャンプ場での素晴らしい夜を楽しんでくださいね」

温かいシャワーで疲れ切った汗を流したあと、合部屋のベットにくるまり今日一日を振り返りながら、世界で一番静かなキャンプ場の夜は流れていった。

⑩朝日のなかで

翌朝、物音で目が覚める。深夜4時。まだ夜明けまで2時間もある。皆に流されて食堂に向かう。もくもくとパンやウィンナーをコーヒーで流し込む。どうやらみんな、早々に朝食を済ませ登山を始めるようだ。

美味しいコーヒーを飲んでいると、キリッとした美しいマダムが話しかけてきた。

「あなた今日一人で昇るんでしょう?? だったこれを持ってきな!」

と戦車のような重装備のリュックから、水筒と大量の非常食を渡してくれた。

「WOW、ありがとうございます!!」

懸念だった水の問題が解決された瞬間だった。こういうところアメリカ人は本当温かい。

真っ暗な登山口、聖なる水を水筒にたっぷり溜め込んで、僕は軽やかに歩き始める。

少しずつ明るくなるグランドキャニオンの奥の奥。見渡す限りの岩の世界。僕は6千人しか見ることができない美しい朝焼けに包まれながら、オゴっていたであっただろう昨日までの自分を振り返り、少しだけ生まれ変わったような新しい気分で、今日からの人生を見つめなおすのだった。

5時間後、温かいバスに揺られながら、マダムの水筒から水をごくごく飲む。無事に登山を終えて、キャンプカーの駐車場へ帰るバスには、心地よい疲労感と充実感が充満していた。

さあ、このままラスベガスに向かって、今夜は世界最大のテックイベントCESに参加するぞ。

2020年が、こうしてはじまったのだった。

おまけへつづく

Raspberry Pi3+ドコデモ人感センサー+Vue.jsで「会議室の空室ディスプレイ」をつくる #16

令和元年のゴールデンウィークは恒例の「ものづくり一人旅」に行ってきた。

今年は島根県の隠岐諸島(海士町とか)をのんびり旅しながら、ラズパイとセンサーを使ってうちの会社で使う「会議室の空室ディスプレイ」を作ってきたので作り方を紹介する。

「会議室見に行く」をなくしたかった

オフィスで会議するとき、こんなことがよくある。

「いまちょっとMTGしよう」 会議室見に行く 「どこもあいてない!」
「あの部屋への来客がきた」 会議室見に行く 「まだ使ってた!」
「あの人まだ会議してるかな?」 会議室見に行く 「まだしてそう!」

こういう「会議室見に行く」をなくしたかった。その会議室が使用中かどうかリアルタイムにわかれば、いちいち会議室に見に行く必要がなくなる

ついでに、

「あの部屋なかに人いる??」
「勢いよく飛び込んだら重役会議中で気まずい・・」

などのよくある課題も解決できる!

最近人が増えて会議室も埋まりがちで、だいたい1日に1回は「会議室見に行く」「そして帰ってくる」が発生している。

なんと年間120万円の大損失!?

試しに1年間でどれぐらいの損失になっているか計算すると・・

①1日で1分「会議室見に行く」が発生とする。
②60名なので1日で1時間の損失
④1ヶ月だと2日分の損失。
③1人の1日の生産力は約5万円とすると・・
⑤1ヶ月で10万円、1年で120万円の損失!

これは大変。早くなんとかしなくては! 全然関係ないけど写真は社内の紙ひこうき大会の様子。無駄な時間を節約して、紙ひこうきであそぼう笑。

つくりかた【ダイジェスト】

空室ディスプレイを作るのに必要なのは、この7ステップ。

①ドコデモ人感センサーを動かす
②センサーのJSON APIを叩く
③CSSで画面をデザイン
④Vue.js+センサーAPIで空室案内表示
⑤ラズパイのセットアップ
⑥ラズパイ上で空室案内表示
⑦ラズパイの自動起動設定

センサーの情報がディスプレイに表示されるまでのデータの流れはこういう感じ。

★ドコデモ人感センサー

↓<人を感知>どこでもセンサー社のクラウドにデータをアップ

★クラウドサービス

↓<JSから随時リクエスト>センサーデータをラズパイのJSに返す

★ラズパイ(JS)

↓<常時表示>5分無反応なら空室と表示。10秒毎に更新

★小型ディスプレイ

————

ちなみに今回つかった道具たち。窓の向こうではニワトリが騒がしい笑。

①ドコデモ人感センサーを動かす

センサーをいろいろと探してみたところ、プラネットコミュニケーション社のどこでも人感センサー WS-USB02-PIRが良さそう。

理由1:シンプルで値段も手頃
理由2:センサーデータをクラウドに1ヶ月保管(無料!)
理由3:API使える(2019.4リリース。ギリギリ間に合った!)

USBでコンセントにさせるシンプルな構造。(隠岐ジオパークキャンプ場にて笑)

電源に指したらセンサーのIPアドレスにブラウザからWifiアクセス(センサーをルータと見立てる感じ)。実際に使うWifi環境のSSID/PASSを入力する。開発中はいちいち変えるのが面倒なので自分のiPhoneのテザリングWifiを指定。あとはコンセントにさすだけで自動でセンサースタート。しばらく計測するとクラウドの管理画面で検知ログがグラフで見れる。いいね!

②センサーのJSON APIを叩く

さあ次はさっそくセンサーが感知したログをAPIで取得する。このAPIの動作確認がまあまあ大変だった。センサーのメーカーはソフト開発が苦手なのか、管理画面がワードプレスだったり、API資料が10行ぐらいの説明で済まされてたり、その取得方法の例が間違ってたり、エラーのときレスポンスにエラーメッセージはなく[]だけだったりと、なかなかの突き放し方・・。だがそれはそれで楽しいからよし笑。

APIをブラウザで叩いてちゃんとレスポンスが帰ってくるまで何が正しいかわからず意外に時間がかかってしまったので、後発組のために正しい使用例を下記に記載しておく。(%22は”, %20はスペースのエスケープ)

リクエストURL
https://svcipp.planex.co.jp/api/get_data.php?type=%22WS-USB02-PIR%22&mac=%2224:72:60:40:**:**%22&from=%222019-04-30%2011:00:00%22&to=%202019-04-30%2012:00:00%22&token=%2211e1277b5e1619561ccd7a1b9977****%22

レスポンスデータ
[
[“2019-04-30 11:00:00”, “24”]
,[“2019-04-30 11:00:06”, “24”]
,[“2019-04-30 11:00:10”, “24”]
,[“2019-04-30 11:00:16”, “24”]
]

③CSSで画面をデザイン

動作確認も出来たところでいよいよ開発開始。まずはCSSで画面をデザイン。遠くの人からでも見えるように、文字を最大限おおきく表示。またそれでも読めないかも知れないので、文字の背景を白で塗る。

この背景はゲージになっていて5分かけて下がっていくようにした。このセンサーは厳密に「入室」「退室」が取れるわけじゃなくて、人の動きを検知するだけ。実際に会議室で試したところ、人がいたとしてもあんまり動かないと検知しないことがわかった。それでも、だいたい5分に1回は人が動いて検知することが経験的に判ったので、「直近5分反応がなかったら空室とみなす」という仕様にした。

ゲージが減っているときに空室かどうか明確に判断はできないけど、それも含めてユーザに伝わる仕組みだ。このあたりハードの仕様や性能限界に合わせて最適なUI/UXを考えて、それをデザインで表現して、説明せずともシンプルで使いやすくするセンスがとても大事だと思う

フォントはBebas Neueっていう電光掲示板っぽいおしゃれフリーフォントを採用。

さらに、この手のシステムはまったく動きがないと、フリーズしてないか?なんらかのエラーが裏で起きてないか?不安になったりするので、常にちょっとでも動きがあったほうが良い。ということで、なんにも反応を検知してないときは、時計を表示した。

④Vue.js+センサーAPIで空室案内表示

いよいよVue.jsを使って、センサーAPIから取得した空室状況を画面に表示する。react.jsとか素のjsでも良かったけど、コードがシンプルに書けて学習が簡単そうで流行ってるという理由でVue.jsに決定。今回のソースは200行に収まっていて本当にシンプル!

Vue.jsのいいところは、モデル(HTML)+画面デザイン(CSS)+ロジック(JS)を1つの「コンポーネント」という単位で扱えて、再利用するときにまるごと再利用できてコードがシンプルになりやすい。今回まさに1つの部屋のコンポーネントをつくってそれを5回繰り返して5部屋の表示処理を実現した。

さらにWEBサーバをたてなくてもローカルのブラウザで動作するのも楽。ただしPCのChromeからアクセスしようとすると、CORSというJSのエラー。どうやらローカルのhtmlファイルからAJaxで外部サーバにアクセスしようとするとセキュリティのエラーになるらしい。これはAllow-Control-Allow-OriginというChromeのオプションでOFFにすることができる。

ソースは下記の通り!

★AiThema10.html (メインプログラム)


<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="google" content="notranslate">
    <title>空室案内:アイテマテン!</title>
    <script src="https://cdn.jsdelivr.net/npm/vue"></script>
    <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.10.6/moment.js"></script>
    <link rel="stylesheet" type="text/css" href="AiThema.css"></link>
</head>
<body>
  <div class="block" id="roomViewer">
      <room-viewer v-for="(room, index)  in rooms" :index="index"></room-viewer>
  </div>
  <div class="clock" id="nowClock" v-if="isEmpty" >
    {{NowTime}}
  </div>
</body>
<script>

  /* -----------------------------------------------共通初期設定----------------------------------------------- */
  const emptyTime       ='300';   //何秒たったら空室とみなすか
  const timerInterval   = 1000;   //何ミリ秒ごとに更新するか
  const requestInterval = 10;     //何秒ごとにサーバに確認するか

  //接続先の部屋一覧とデバイスの接続設定。最後の2つは1.最新検知日時,2.最新アクセス日時
  var rooms = [
    {'id': "S", 'mac': "24:72:60:40:**:**", 'token': "11e1277b5e1619561ccd7a1b9977****",'hit_date':"",'update_date':""},
    {'id': "T", 'mac': "24:72:60:40:**:**", 'token': "d6a0e1de99d2bbae39cd6cf043e0****",'hit_date':"",'update_date':""},
    {'id': "B", 'mac': "24:72:60:40:**:**", 'token': "38caa196230f2b19d9a6ac4397d5****",'hit_date':"",'update_date':""},
    {'id': "M", 'mac': "24:72:60:40:**:**", 'token': "1427bef41f36d90efb95aae0bda2****",'hit_date':"",'update_date':""},
    {'id': "C", 'mac': "24:72:60:40:**:**", 'token': "94781891187c4cbd72fe8a510a05****",'hit_date':"",'update_date':""}
  ]

  //センサーAPIのURL
  var url = "https://svcipp.planex.co.jp/api/get_data.php?type=WS-USB02-PIR&mac=$mac&from=$fromDate&to=$toDate&token=$token"

  /* ----------------------------メインコンポーネントの設定。APIへアクセスし空き状況表示------------------------------ */
  Vue.component('room-viewer', {
    props: ['index'],

    /* ----------------共通変数の宣言---------------- */
    data: function () {
      return{
        roomName:[],            //表示する部屋名(1文字)
        pastSecond:emptyTime,   //前回反応してから何秒だったか? デフォでは空室とみなす
        reloadTimer: null,      //定期的に画面をリロードするタイマー
        lastRequest:''          //前回リクエストした時刻を保管。リクエストのインターバルを取るため
      }
    },

    /* ------------初期化・タイマーのセット------------ */
    created: function() {

      let self = this;

      Vue.set(self.roomName, self.index, rooms[self.index].id); //部屋名を表示 (エラーがでたら部屋名は?になる)
      console.log("roomName:"+this.roomName[this.index]);

      this.reloadTimer = setInterval(function() {self.checkStatus()}, timerInterval);  //定期的に状況確認を呼び出す

    },

    /* ----------------メソッド宣言---------------- */
    methods: {

      /****** サーバへ検知状況をチェックするメソッド ******/
      checkStatus: async function(){

        var now =  moment(new Date).format('YYYY-MM-DD HH:mm:ss');

        //前回の検知から何秒経過した?
        pastSecond =  moment(now).diff(moment(rooms[this.index].hit_date),'seconds');
        this.statusView();

        //リクエスト間隔が設定値に達しなければ何もせず待つ
        if(this.lastRequest!=''
            & moment(now).diff(moment(this.lastRequest),'second') < requestInterval){
              return;
        }

        this.lastRequest = now;

        //サーバ接続処理開始

        var sensorData = [];  //センサーから反応時刻を取得
        var reqUrl = url;     //センサーのAPI URL

        //リクエスト用のデバイス情報
        reqUrl = reqUrl.replace('$mac'  ,rooms[this.index].mac);
        reqUrl = reqUrl.replace('$token',rooms[this.index].token);

        //今から10分前までのログを取得 urlに設定
        var m = moment(new Date).add(-9, "hours");  //なぜかセンサーが9時間前のutcなので調整
        reqUrl = reqUrl.replace( '$toDate', m.format('YYYY-MM-DD HH:mm:ss') );
        reqUrl = reqUrl.replace( '$fromDate', m.add(-10, "minutes").format('YYYY-MM-DD HH:mm:ss') );

        console.log(reqUrl);

        try{
          //センサーのAPIにリクエスト
          var res = await axios.get(reqUrl);
          sensorData = res.data;
          console.log("resdata:"+sensorData);

          Vue.set(this.roomName, this.index, rooms[this.index].id); //部屋名を改めて指定(?かもしれないので)
          console.log("roomName:"+this.roomName[this.index]);


        } catch (error) {
          Vue.set(this.roomName, this.index, '?');  //エラーの場合ディスプレイに?とだす
          console.log("エラー発生..."+error);
        }

        //最新検知日時が取れれば、保存する
        if(sensorData.length!=0){       //何らかのデータが帰ってくれば格納
          console.log("api_lastdata:"+sensorData[sensorData.length-1][0]);
          rooms[this.index].update_date = now;

          //UTF->JST変換して格納
          rooms[this.index].hit_date = moment(sensorData[sensorData.length-1][0])
                                        .add(9, "hours").format('YYYY-MM-DD HH:mm:ss');
        }


      },

      /****** 空室状況を画面に表示するstyleの調整 ******/
      statusView: function(){

        //console.log("Last Hit:"+rooms[this.index].hit_date+" pastSecond:"+this.pastSecond);

        if(rooms[this.index].hit_date!=""){
          var hitMoment = moment(rooms[this.index].hit_date); //前回検知時間
          this.pastSecond = moment(new Date()).diff(hitMoment, 'seconds');  //何秒経過した?
        }
        var blackPer = (this.pastSecond * 100) / emptyTime;  //進捗率100%に治す
        blackPer = Math.floor(blackPer);                     //整数になおす
        blackPer = Math.min(blackPer,100);                   //最大100%に丸める
        //console.log("blackPer:"+blackPer);

        var charColor = "red"; //0〜25%

        if(blackPer>=25&&blackPer<50){ //〜50%
          charColor = "#FF4500";
        }
        else if(blackPer>=50&&blackPer<75){ //〜75%
          charColor = "#FF7F50";
        }
        else if(blackPer>=75&&blackPer<100){
          charColor = "#FFA07A";
        }
        else if(blackPer==100){
          charColor = "white";
        }
        return{
          background: 'linear-gradient(black '+blackPer+'%, white '+(100-blackPer)+'%)',
          color: charColor
        }
      }
    },

    /* ----------------表示設定---------------- */
    template:
      '<div class="cell_basic" v-bind:style="statusView()">'+
        '{{this.roomName[this.index]}}'+
      '</div>'
  })

  /* --------------------------------Vueの宣言-------------------------------- */
  new Vue({  //メイン
    el: '#roomViewer',
  })

  new Vue({  //時計
      el: '#nowClock',
      data: {
        NowTime: '', //testValを定義
        isEmpty: ''
      },
      created:  function () {
        let self = this;
        setInterval(function(){self.setDate()}, 500);
      },
      methods: {
        setDate: function(){
           this.NowTime = moment(new Date).format('HH:mm:ss') //現在時刻を返す
           this.isEmpty = true;
           for (var index in rooms) {
             if(rooms[index].hit_date!="" &
                moment(new Date).diff(moment(rooms[index].hit_date),'seconds') < emptyTime){
                  this.isEmpty = false;
                }
             }
        }
      }
    });
</script>
</html>

★AiThema.css

body {
    background-color:black;
    overflow:hidden;
    margin: 0px;
}

.block{
  display: table;
  width: 100vw;
  height: 100vh;
  text-align: center;
  font-size: 45vw;
  font-family:  "Bebas Neue";
}

.cell_empty{
  color : white ;
  display: table-cell;
  vertical-align: middle;
  border: 0.5px solid black;
}

.cell_basic{
  display: table-cell;
  vertical-align: middle;
  border: 0.5px solid black;
}

.clock{
  display: table;
  width: 100vw;
  height: 10vh;
  text-align: center;
  font-size: 5vw;
  font-family:  "Bebas Neue";
  color : white ;
  margin:  0px;
  position:  absolute;
  bottom: 2vh;
  z-index: 10;
}

※var rooms[]の設定とcssの.blockのfont sizeを変えるだけで、部屋が5個じゃなくても動作可能
(デザイン的には2〜7部屋ぐらいがちょうどよい)

⑤ラズパイのセットアップ

さあいよいよラズパイ上でのこのプログラムを動作させよう。

まずは、ラズパイ+外付けディスプレイを接続してWifiの設定しようとしたら問題発生!MACからラズパイにアクセスする方法がなにもない。。Wifiの設定はどうしても外付けキーボードで直接つないでやらないといけない。でもここは人口2000人の離島。。PCショップなんてどこにもない。途方に暮れているところ、民宿のおじさんがキーボードを持っていた!ベタベタでCtrlキーもなくなってたけど、全然問題なし。ありがとうおじさん。

1. まずWifiの設定

移動するごとに毎回設定するのは面倒なので、iPhoneのテザリングに指定。さらにホスト名を設定してipアドレスを毎回入れなくて良いように。

$ssh pi@raspberrypi.local

で同じネットワーク内にいればログインできる。簡単

2. 初期セットアップ

最新OSをインストールしたり、地域を設定したり。ネットで探すとたくさん記事がある。

3. ファイルをラズパイに配置

これもscpコマンドで簡単における。htmlとcssを置くだけ。

$scp -r AiThema10 pi@raspberrypi.local:/home/pi/Desktop/

4. fontのインストール

“Bebas Neue”フォントがラズパイ内にないのでインストール。apt-getでも登録されていないので、直にBebas Neueの配布サイトからダウンロードして.fontsフォルダに配置するだけ。簡単。

$scp BebasNeue-Regular.ttf pi@raspberrypi.local:/home/pi/.fonts/

これだけでラズパイ上での動作設定が完了!

5. ブラウザを起動する

下記のコマンドでchromeが起動される。

chromium-browser --noerrdialogs --kiosk --incognito --disable-web-security --user-data-dir --test-type /home/pi/Desktop/AiThema10/AiThema10.html

#--noerrdialogs エラーダイアログを表示しない
#--kiosk 全画面表示
#--incognito シークレットモードで起動
#--disable-web-security chromeのextension同様、ローカルからAjaxを読むため
#--user-data-dir 同上。この②個セット
#--test-type ヘッダーにwarningが出たりするのでそれが出ないように

⑦ラズパイの自動起動設定

あっという間に設定までが完了!最後にオフィスで使うための細かな設定。

1. OS起動時に自動起動

電源を入れるだけで誰でも使えるように、OS起動後に自動的にブラウザ起動して全画面表示にしたい。autostartファイルを編集すると、自動起動の設定ができる。ついでにマウスポインタも非表示時に。

マウス無効化モジュールをインストール

$sudo apt-get install unclutter

自動起動の設定ファイルを編集

$sudo vi .config/lxsession/LXDE-pi/autostart
#下記は無効化
#@lxpanel --profile LXDE-pi
#@pcmanfm --desktop --profile LXDE-pi
#@xscreensaver -no-splash
#@point-rpi

#スクリーンセーバーをオフに
@xset s off

# X serverをオフに
@xset -dpms

# DPMS (Display Power Management Signaling) をオフに
@xset s noblank

#マウスポインタを非表示
@unclutter

#自動起動
@chromium-browser --noerrdialogs --kiosk --incognito --disable-web-security --user-data-dir --test-type /home/pi/Desktop/AiThema10/AiThema10.html

2.深夜土日はOFFに

省エネのために、使う可能性が低いときはスリープモードにしておきたい。スリープ状態では、ブラウザを停止して、ディスプレイもOFFにする。再開したいときには再起動するだけでOK。cronに設定する。

$sudo crontab -e
#平日の朝10時に再起動
0 10 * * 1-5 /sbin/shutdown -r now

#平日の夜23時にブラウザ停止とディスプレイをスリープ
0 23 * * 1-5 pkill chromium &amp;&amp; sudo service lightdm stop

以上で完了! ちょくちょくハマったけど終わってみれば本当に簡単だった。良き時代!

社内に設定してみた

完成したので早速社内に設置。誰の机からもいまの会議室の空き状況がわかるようになり、いちいち見に行かなくて良くなった。ただし、「説明しなくても直感でわかるデザイン」ということで、特に説明なく置いていたら「気温の表示ですか?」と言われてしまった笑。。もうすこしUI改善の余地あり。また、個人的に世界一やさしいメッセージだと思う「人がいなくても水が流れることがあります by TOTO」と同じ原理で、誰もいないのに反応してしまうことがある。このあたりの誤認識をスルーするようなアルゴリズムはまだ改善の余地あり。

かかった費用は4万円ぐらい

全体的に思ってたより安い。原価4万円で年間120万円の節約に成功!

Raspberry Pi3 ×1 9,999円
ディスプレイ ×1 7,299円
ドコデモ人感センサー ×5 20,810円
延長コード ×5 3,590円

合計:41,690円

さいごに:ハードもソフトの時代へ

これまでソフトウェアエンジニアは家電とかハードについてはどこか遠くの存在だったけど、ラズパイやIoTデバイスの普及などによって簡単にリアルな空間で使える装置を作れるようになった。今回の開発でとくに簡単さを強く実感した。もうPCやスマホに収まっている時代ではない。

ソフトもハードも一体となって、かんたんなプログラミング感覚でリアルものづくりができる時代には、技術力よりもむしろ

①リアルなシーンや人の気持ちを理解してUI/UXにを設計するデザイン力
②とりあえずプロトを作ってみて良かったら事業化するビジネス力

などがとっても大事になってくる。

ということで当社でもそんなビジョンをもったエンジニア&クリエイターをまだまだ増やしていくので、興味のある方は会社HPから連絡まってます!

おまけ

隠岐ジオパークキャンプ場でリアルプログラムキャンプの様子笑。隣のテントのソロキャンパーと釣った魚をBBQして遊んだりチャリで離島を一周したりしながらの、年に一度の至福の時間だった。

ではでは!