クラウドAI＆ローコードを使ってウケるサービスを2時間で作ってみた

# 導入

何を隠そう、今日は私の誕生日です。
今日は皆さんに、スピード開発の醍醐味をお伝えできればと思います。

突然ですが、バチェラー・ジャパン シーズン３はご覧になりましたか？
私は不覚にも最後まで見てしまいました。
個人的にはあの状況で相手を傷つけずにコメントを上手くまとめる指原のMCスキルに感動しました。
中身はどうでもいいですが、巷では友永構文とやらが流行っているではありませんか。
乗るしかない、このビッグウェーブに。
アプリを作りましょう。２時間で。
誰かに先を越されて二番煎じになっては水の泡です。思い立ったら直ぐ形にできるのがプロです。

# 何を作るか
友永構文とは何か。
要は、バチェラーとして登場した友永氏の言い回しを真似した文章で、噂によるとAIでも作れるらしいです。

>　＜友永構文のパターン＞
>　・ほんまに～（ありがとう、嬉しいなどポジティブな言葉）
>　・正直、～でした
>　・真剣に考えました
>　・めちゃくちゃ考えました
>　・包み隠さず全て話します
>　・そこに関して何の嘘もないです
>　・～の強さがどこから来たのか……
>　・感謝しかないですね

予めいくつかの言い回しで文章を用意して、主語や目的語を差し替えてあげるだけでもそれっぽくなりそうです。
ということで、ユーザーからは、

- 適当な文章
- 適当な画像

を貰って、それをヒントに単語を取り出し、予め用意した文章に当てはめることにします。

先に、できたものを見たほうが早いですね。
こんなものができました。→[友永構文メーカー](https://tomonaga.glideapp.io)


# 何を使うか

でもリミットは２時間です。
コード書いてる暇は無いので、APIを活用します。

- Azure Computer Vision
    - 画像を投げると、要素を抜き出してテキストを返してくれます。
    - 画像と応答の例

![alt](https://firebasestorage.googleapis.com/v0/b/glide-prod.appspot.com/o/img-uploads%2FsADhykvPFvX6I5ObJvny%2F849685.jpeg?alt=media)

```json:response.json
{
  "categories": [{
    "name": "屋外_",
    "score": 0.00390625,
    "detail": {
      "landmarks": []
    }
  }, {
    "name": "人_",
    "score": 0.6796875,
    "detail": {
      "celebrities": []
    }
  }],
  "description": {
    "tags": ["グリーン", "ラケット", "持つ", "裁判所", "人", "立つ", "男", "選手", "ボール", "水", "女性", "フロント", "モニター", "記号", "スポーツゲーム", "手", "話す", "一致", "シャツ", "再生", "電話", "部屋", "ゲーム", "ストリート", "振る", "ホワイト"],
    "captions": []
  },
  "requestId": "a99e9b80-979e-400e-95af-77fda3e5582b",
  "metadata": {
    "width": 1440,
    "height": 900,
    "format": "Jpeg"
  }
}
```

- gooラボ 固有表現抽出API
    - 教えてgooを運営しているNTTが提供しているAPIです。今回は、エンティティ（人名、地名など）を抜き出してくれるAPIを利用させてもらいます。無料です。

```json:input.json
{
  "app_id":"[発行されたapp_id]",
  "request_id":"record002",
  "sentence":"鈴木さんがきょうの9時30分に横浜に行きます。"
}
```
```json:response.json
{
  "ne_list": [
    ["鈴木","PSN"],
    ["9時30分","TIM"],
    ["横浜","LOC"]
  ],
  "request_id": "record002"
}
```

- Google Suggest API
    - サジェストって、検索ワード入れると次のワードを予測してくれる、アレです。芸能人を入れると過去の不祥事丸わかりです。怖いですね。
    - [「令和」の例（iPhoneは表示不可）](https://www.google.com/complete/search?hl=ja&q=%E4%BB%A4%E5%92%8C&output=toolbar)

```xml:response.xml
<toplevel>
<CompleteSuggestion>
<suggestion data="令和"/>
</CompleteSuggestion>
<CompleteSuggestion>
<suggestion data="令和 いつから"/>
</CompleteSuggestion>
<CompleteSuggestion>
<suggestion data="令和1年"/>
</CompleteSuggestion>
<CompleteSuggestion>
<suggestion data="令和2年"/>
</CompleteSuggestion>
<CompleteSuggestion>
<suggestion data="令和元年"/>
</CompleteSuggestion>
<CompleteSuggestion>
<suggestion data="令和元年度"/>
</CompleteSuggestion>
<CompleteSuggestion>
<suggestion data="令和元年 平成"/>
</CompleteSuggestion>
<CompleteSuggestion>
<suggestion data="令和 西暦"/>
</CompleteSuggestion>
<CompleteSuggestion>
<suggestion data="令和天皇"/>
</CompleteSuggestion>
<CompleteSuggestion>
<suggestion data="令和元年 令和1年"/>
</CompleteSuggestion>
</toplevel>
```



# 機能と仕組み
まとめると、こうです。

- 画像から生成する
    - ユーザーから画像を受け取る
    - 画像をAzureに投げて、写ってるものをテキストで受け取る　→①
    - 画像をAzureに投げて、写ってる有名人をテキストで受け取る
    - 有名人が取れたらGoogle Suggest APIで関連ワードを受け取る　→②
    - ①か②か良さげな方（要素の数が一定数揃った方）を使って文章を作る
    - ①画像例
![alt](https://firebasestorage.googleapis.com/v0/b/glide-prod.appspot.com/o/img-uploads%2FwgL0JdkDmy5zVYWe8SLV%2F803750.jpeg?alt=media)
    - ②画像例
![alt](https://firebasestorage.googleapis.com/v0/b/glide-prod.appspot.com/o/img-uploads%2FMevxDDB6IQ2SCxiMJ9XE%2Fdeb887.jpeg?alt=media)

```ruby:①出力例
ほんまに気づきました。<BR>
もっと、オーブンで焼かれた食品について本気で考えるべきやって。<BR>
これはファスト フードかもしれない。<BR>
でも、そんなん些細なことやなって。<BR>
テーブルでもおれれるなって。<BR>
ケーキ、ほんまに感謝してるで。<BR>
今度こそはきっと、サワードウ返そうって。<BR>
いや、グラハム パンだろうって？<BR>
信じてほしい、ライ麦パンにかけて、100%嘘は無いです。<BR>
そんな、ビガについて考えているんです。<BR>
#バチェラー<BR>
#友永構文
```


```ruby:②出力例
真剣に、気づきました。もっと、フランシスコッポラについて本気で考えるべきやって。<BR>
これはザビエルかもしれない。<BR>
でも、そんなん些細なことやなって。<BR>
フランシスコでもおれれるなって。<BR>
ッポラワイン、めっちゃ、感謝してるで。<BR>
今度こそはきっと、ピサロ返そうって。<BR>
いや、ッポラワイナリーだろうって？<BR>
信じてほしい、フランコにかけて、100%嘘は無いです。<BR>
そんな、会について考えているんです。<BR>
#バチェラー<BR>
#友永構文
```



- 文章から生成する
    - ユーザーから文章を受け取る
    - 文章をgoo APIに投げて、エンティティを受け取る　→①
    - エンティティの中から人名、地名、組織名のいずれかをGoogle Suggest APIに投げ、関連ワードを受け取る　→②
    - ①か②か良さげな方（要素の数が一定数揃った方）を使って文章を作る

```ruby:①入力例
ミスチルの櫻井さんと東京の「スカイツリー」で12/24 15時から500万円でライブ契約。
3万人の会場で98%目標。
```

```ruby:①出力例
ミスチルの櫻井、正直、好きやで。<BR>
正直、スカイツリーのこと見くびってました。<BR>
俺、神戸じゃなくて東京に行くべきやって。<BR>
12/24気づいたんです。<BR>
そこで、言い訳する時間、15時間ももらえないです。<BR>
明日こそは、きっと500万円を返そうって、<BR>
ほんまに98%、いや、100%嘘はないです。<BR>
そんな未来について考えているんです。<BR>
#バチェラー<BR>
#友永構文
```
```ruby:②入力例
広瀬さん、こんにちは。
〜以下省略〜
```
```ruby:②出力例
正直、気づきました。もっと、広瀬すずについて本気で考えるべきやって。<BR>
これはアリスかもしれない。<BR>
でも、そんなん些細なことやなって。<BR>
香美でもおれれるなって。<BR>
隆雄、正直、感謝してるで。<BR>
今度こそはきっと、裕也返そうって。<BR>
いや、すず兄だろうって？<BR>
信じてほしい、隆にかけて、100%嘘は無いです。<BR>
そんな、すずアリスについて考えているんです。<BR>
#バチェラー<BR>
#友永構文
```

- つぶやく
    - せっかくいい文章が出来たら人はシェアしたいはずなので、Tweetできる機能も載せます。
    - Twitter投稿画面のURLのクエリパラメータに投稿内容を付けるだけでワンクリックで投稿できます。
    - スマホでTwitterアプリを入れてる場合、勝手にアプリが立ち上がるので便利。
    - 投稿する文章の全角は、URLエンコードが必要です。1文字ずつ、UTF-8のバイトコードの前に「%」をつけた形で表記します。
    - [リンク例](https://twitter.com/intent/tweet?text=%E3%82%81%E3%81%A3%E3%81%A1%E3%82%83%E3%80%81%E6%AD%A3%E7%9B%B4%E3%80%81%E4%BE%BF%E5%88%A9%E3%82%84%E3%81%AA%E3%81%A3%E3%81%A6%E3%80%81%0A%E3%81%93%E3%81%AE%E3%82%A2%E3%83%97%E3%83%AA%E4%BD%BF%E3%81%A3%E3%81%A6%E6%B0%97%E3%81%A5%E3%81%8D%E3%81%BE%E3%81%97%E3%81%9F%E3%81%AD%E3%80%82%0A%E3%81%93%E3%82%8C%E4%BD%BF%E3%81%A3%E3%81%A6%E3%82%82%E5%8F%8B%E6%B0%B8%E3%81%AB%E3%81%AF%E3%81%AA%E3%82%8C%E3%81%AA%E3%81%84%E3%81%8B%E3%82%82%E3%81%97%E3%82%8C%E3%81%AA%E3%81%84%E3%80%82%0A%E3%81%A7%E3%82%82%E3%80%81%E3%81%9D%E3%82%93%E3%81%AA%E3%82%93%E4%BA%9B%E7%B4%B0%E3%81%AA%E3%81%93%E3%81%A8%E3%82%84%E3%81%AA%E3%81%A3%E3%81%A6%E3%80%82%0A%E3%81%9D%E3%81%93%E3%81%AF%E3%81%8A%E3%82%8C%E3%82%8C%E3%82%8B%E3%81%AA%E3%81%A3%E3%81%A6%E3%80%82%0A%E6%98%8E%E6%97%A5%E3%82%82%E3%81%8D%E3%81%A3%E3%81%A8%E5%8F%8B%E6%B0%B8%E6%A7%8B%E6%96%87%E4%BD%9C%E3%82%8B%E3%80%81%0A%E3%81%9D%E3%82%93%E3%81%AA%E6%9C%AA%E6%9D%A5%E3%81%8C%E8%A6%8B%E3%81%88%E3%81%BE%E3%81%97%E3%81%9F%E3%81%AD%E3%80%82%0A%23%E5%8F%8B%E6%B0%B8%E6%A7%8B%E6%96%87%0A%23%E3%83%90%E3%83%81%E3%82%A7%E3%83%A9%E3%83%BC%0Ahttps%3A%2F%2Ftomonaga.glideapp.io)

```ruby:リンク例
https://twitter.com/intent/tweet?text=%E3%82%81%E3%81%A3%E3%81%A1%E3%82%83%E3%80%81%E6%AD%A3%E7%9B%B4%E3%80%81%E4%BE%BF%E5%88%A9%E3%82%84%E3%81%AA%E3%81%A3%E3%81%A6%E3%80%81%0A%E3%81%93%E3%81%AE%E3%82%A2%E3%83%97%E3%83%AA%E4%BD%BF%E3%81%A3%E3%81%A6%E6%B0%97%E3%81%A5%E3%81%8D%E3%81%BE%E3%81%97%E3%81%9F%E3%81%AD%E3%80%82%0A%E3%81%93%E3%82%8C%E4%BD%BF%E3%81%A3%E3%81%A6%E3%82%82%E5%8F%8B%E6%B0%B8%E3%81%AB%E3%81%AF%E3%81%AA%E3%82%8C%E3%81%AA%E3%81%84%E3%81%8B%E3%82%82%E3%81%97%E3%82%8C%E3%81%AA%E3%81%84%E3%80%82%0A%E3%81%A7%E3%82%82%E3%80%81%E3%81%9D%E3%82%93%E3%81%AA%E3%82%93%E4%BA%9B%E7%B4%B0%E3%81%AA%E3%81%93%E3%81%A8%E3%82%84%E3%81%AA%E3%81%A3%E3%81%A6%E3%80%82%0A%E3%81%9D%E3%81%93%E3%81%AF%E3%81%8A%E3%82%8C%E3%82%8C%E3%82%8B%E3%81%AA%E3%81%A3%E3%81%A6%E3%80%82%0A%E6%98%8E%E6%97%A5%E3%82%82%E3%81%8D%E3%81%A3%E3%81%A8%E5%8F%8B%E6%B0%B8%E6%A7%8B%E6%96%87%E4%BD%9C%E3%82%8B%E3%80%81%0A%E3%81%9D%E3%82%93%E3%81%AA%E6%9C%AA%E6%9D%A5%E3%81%8C%E8%A6%8B%E3%81%88%E3%81%BE%E3%81%97%E3%81%9F%E3%81%AD%E3%80%82%0A%23%E5%8F%8B%E6%B0%B8%E6%A7%8B%E6%96%87%0A%23%E3%83%90%E3%83%81%E3%82%A7%E3%83%A9%E3%83%BC%0Ahttps%3A%2F%2Ftomonaga.glideapp.io
```



# 実装

さて、役者は揃いました。
ここから2時間を目標に実装してみます。
![構成.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/259832/c6b5b295-895d-70ba-1dfc-5a6977cc4a3e.png)

UIはGlideというローコードツールで省力化します。
裏側はAPIを呼ぶ処理をGASで書き、スプレッドシートから呼び出します。

```Javascript:tomonaga.gs
function face(str){
  // POSTデータ
  var payload = {
    "url": str
  };
  var headers = {
　　　　"Ocp-Apim-Subscription-Key": "＜APIキー＞",
　　　　"Content-Type": "application/json"
  };

  // POSTオプション
  var options = {
    "method" : "POST",
    "headers": headers,
    "payload" : JSON.stringify(payload),
    "muteHttpExceptions": true,
    "json": true
  };
  // アクセス先
  var url = "https://eastasia.api.cognitive.microsoft.com/vision/v2.0/analyze?visualFeatures=Faces&details=Celebrities&language=ja";
  // POSTリクエスト
  var response = UrlFetchApp.fetch(url, options).getContentText();
  Logger.log("response:" + response);
  var response_json = JSON.parse(response).categories[0].detail.celebrities[0].name
  Logger.log("response_json:" + response_json);
  
  //  return response_json;
  if (response_json.length > 0){
    return response_json;
  }
  else{
    return "";
  }
}
function entity2(sentence,class) {
/**
 * Convert Japanese KANJI to KATAKANA by goo LAB API.
 *
 * @param {class} input The value of class_filter.
 * @ART(人工物名)、ORG(組織名)、PSN(人名)、LOC(地名)、DAT(日付表現)、TIM(時刻表現)、MNY(金額表現)、PCT(割合表現)のうち、出力する情報を文字列で指定します。複数指定する場合は、"|"で区切って複数記載してください。
 * @author Yosuke Suzuki
 */
  //sentence = "こんにちは、今日は晴れていますと鈴木さんが言いました。";
  //class = "PSN";
  var endpoint = "https://labs.goo.ne.jp/api/entity";
  var rc = Logger.log(sentence);
  var payload = {
    "app_id": "＜APIキー＞",
    "request_id":"record001",
    "sentence":  sentence,
    "class_filter" : class
    //ART(人工物名)、ORG(組織名)、PSN(人名)、LOC(地名)、DAT(日付表現)、TIM(時刻表現)、MNY(金額表現)、PCT(割合表現)のうち、出力する情報を文字列で指定します。複数指定する場合は、"|"で区切って複数記載してください。
  };
  var options = {
    "method": "post",
    "payload": payload
  };

  var response = UrlFetchApp.fetch(endpoint, options);
//  var response_json = JSON.parse(response.getContentText());
//  var response_json = JSON.parse(response).ne_list.pop();
  var response_json = JSON.parse(response).ne_list;
  var rc = Logger.log(response_json);
  var rc = Logger.log(JSON.parse(response).ne_list);
  var rc = Logger.log(response_json.length);

  if (response_json.length > 0){
    return response_json;
  }
  else{
    return "";
  }
};

function tags(str){
  // POSTデータ
  var payload = {
    "url": str
  };
  var headers = {
　　　　"Ocp-Apim-Subscription-Key": "＜APIキー＞",
　　　　"Content-Type": "application/json"
  };

  // POSTオプション
  var options = {
    "method" : "POST",
    "headers": headers,
    "payload" : JSON.stringify(payload),
    "muteHttpExceptions": true,
    "json": true
  };
  // アクセス先
  var url = "https://eastasia.api.cognitive.microsoft.com/vision/v2.0/analyze?visualFeatures=Tags&language=ja";
  // POSTリクエスト
  var response = UrlFetchApp.fetch(url, options).getContentText();
  Logger.log("response:" + response);

  // JSONデータを取得
  var len = JSON.parse(response).tags.length;
  Logger.log("len:" + len);
  var Array = [[]];
  for (var c = 0; c < 8 ; c++){
    var c2 = c % len;
    var newLen = Array[0].push(JSON.parse(response).tags[c2].name);
    Logger.log('Array:' + Array[0][c]);
  }
  return Array;
}
```

Urlフェッチのサンプルを引用したらここまで１時間半くらいあればできます。

- Webアプリ
    - 上のGAS関数を、Googleスプレッドシートに書くだけで呼び出せます。

```スプレッドシート:エンティティを取る関数
=entity2(＜入力する文章が入ったセル＞,＜エンティティのクラス（PSNなど）＞)
```
```スプレッドシート:画像に写っているもを取る関数
=tags(＜画像のURL＞)
```

  - スプレッドシート上にAPIから得た情報を集めて文章を組み立てます。（20分）
  - スプレッドシートをGlideに取り込んで、アプリに仕立てます。（10分）
- Glideの使い方は[こちらの記事](https://qiita.com/ozaki25/items/3063037bfc3335fa7186)が詳しいです
 
# 工夫したこと

- 普通はスプレッドシートで関数を書いたセルのみに返り値が入りますが、GASの返りを配列にすると隣のセルにも値を入れられるので、使いようによっては便利です。
    - 1次元配列の場合（Array[x]）→列方向（下方向）のセルに値が入る
    - 2次元配列の場合（Array[0][x]）→行方向（右方向）のセルに値が入る

- XMLの取扱
    - Googleスプレッドシートでは、XMLを取り扱うには、標準の関数が便利です。RSSフィードなど一瞬で抜けます。

```スプレッドシート:GoogleSuggestAPIの結果を取る関数
=IMPORTXML(
  "https://www.google.com/complete/search?
    hl=ja&
    output=toolbar&
    q="&ENCODEURL(
      ＜サジェストを取得するワード＞
    ),
  "//CompleteSuggestion/suggestion/@data")
```


- APIの大量発行
    - 投稿が溜まってきて関数が埋め込まれたレコードが増えてくると、ある問題が発生します。
    - セルのどこかが更新された際に全てのセルで再計算が走り、大量のAPIが一斉に発行され、特に無料のAPIでは利用停止に陥ります。
    - そこで、関数が埋め込まれたセルは頃合いを見計らって固定値に差し替えます。
    - 頃合いがいつかが問題ですが、関数の計算と同期した処理ができず、スリープするとGASの無償範囲となる処理時限が危ないため、次の入力があったら前回分を固定値化する事にします。

- 主語、目的語以外は毎回同じ文章となるのも芸がありません。ランダムで一部変わるようにしてみます。

```スプレッドシート:スプレッドシート関数
=index(
  ＜ランダムに表示させたるテキストのバリエーション一覧＞,
  int(
    rand()*counta(
      ＜ランダムに表示させるテキストのバリエーション一覧＞
    )
  )+1,1
)
```

# 割り切ったこと

- Glideは無償版ではスプレッドシートの行数で500行までしか読み込んでくれません。でも今回はそんなにたくさん投稿されないと思われるので目を瞑ります。
- 画像のサイズが大きすぎる（数MBクラス）とAzureで処理してくれません。ダウンサイジングしてくれるAPIもありますが無償の範囲では限度があるので諦めます。
- 特徴の少ないシンプルな画像だと、Azureも情報をひねり出せず要素の数が足りないことがあります。そんな時はそれっぽい言い回しで「めっちゃ考えたけど、ようわからん、些細なことかもしれへんけど、おれれへん」などと返します。
- 人名、地名などの要素が文章に含まれないと、文章作れないので同様に「おれれへん」などと返します。
- 画像解析やサジェストの結果は名詞が多いですが、たまに動詞や形容詞が紛れます。一方で文章は名詞を想定して作ってるので、変な文章になる事があります。品詞ごとに分類すればいい感じにできますが、コード量的に２時間では収まらなくなるので諦めます。


# 公開
アプリは出来ましたが、重大なことに気づいてしまいました。
このままで一体誰に使ってもらえるのでしょうか。
とりあえず、使ってないTwitterのアカウント（フォロワー0）でつぶやくことで供養としました。


# 1週間後
本記事のタイトル、覚えてますか。
そうです、ウケないと締まりません。

つぶやきの反応を見たところ、フォロワー0のつぶやきに対して1週間で21000件くらいのアクセスがありました。
そのうち1600人がアプリのURLをクリックし、6,700件くらいの投稿がありました。（Glide上限の500件超えてしまったので手作業でメンテ）
初めて世界に自分だけのアプリを公開して使ってもらえて、ちょっと感動。
自分の中では大ヒットです。
皆さんもサービスを作って、リリースしてみては如何でしょうか。