【チーム開発】お笑いWebサービス「Tendon」を公開します！　〜技術編〜

# はじめに
こんにちは、@yomo93 です。
今回は @tkryy 、@KokumaruGarasu と一緒に開発したお笑いWebサービス「Tendon」の技術的な処理の一部を紹介しようと思います。

基本的な内容は[【チーム開発】お笑いWebサービス「Tendon」を公開します！　〜一般編〜](https://qiita.com/yomo93/items/39f7c4e7bdf78f84de1e)をご覧ください。

![Tendonロゴ.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2534042/8206e9f6-b2c6-c960-a1c6-783f5d8be8c6.png)

(リンクはこちら: https://tendon-psi.vercel.app/)

## Comedian型
TypeScriptの型定義を利用して、以下のように芸人データを扱いやすいComedian型を作成しました。

``` Comedian.ts
export type Comedian = {
  //基本属性
  id: string;           // 芸人ID 勝手に決めた文字列(ほぼランダム)
  name: string;         // 芸名(コンビ名)
  birthYear: string;    // 結成年
  company: number;      // 所属事務所 数値を対応させています
  sex: number;          // 性別 数値で対応させています
  member: number;       // 人数
  imageSRC?: string;    // 画像URL 所属事務所のサイトなどから引用するためのURL
  homePageURL?: string; // HPのURL 所属事務所のサイトなどから引用するためのURL

  //芸風に関する数値
  //値がない場合も考慮する
  manzai?: number; // 漫才をするかどうか
  conte?: number;  // コントをするかどうか
  pin?: number;    // ピン芸をするかどうか
  rhythm?: number; // リズム・歌ネタが得意かどうか
  gag?: number;    // ギャグが得意かどうか
  ogiri?: number;  // 大喜利が得意かどうか
  mimic?: number;  // ものまねが得意かどうか
  talk?: number;   // トークが得意かどうか
  sns?: number;    // SNSが得意かどうか

  info: string;    // その他の情報
};

```

## お気に入りボタン
お気に入り機能で使うお気に入りボタンをコンポーネント化させたものを紹介します。
デザイン系はtailwindCSSのdaisyUIというものも使用しています。

ログインせずにお気に入りボタンを押すと、ログインしてくださいというポップアップが出ます。
ログインしている場合はお気に入りへの登録と削除を交互に行います。

``` typescript
"use client";
import { useState, useEffect } from "react";
import { useUserDataStore } from "@/lib/zustand/Stores";
import { useAuth } from "@/lib/firebase/context";
import axios from "axios";
import Image from "next/image";
import Link from "next/link";
import { Comedian } from "@/models/Comedian";

interface FavoriteAddButtonProps {
  comedian: Comedian;
}

export default function FavoriteAddButton({
  comedian,
}: FavoriteAddButtonProps) {
  const [comedians, setComedians] = useState<Comedian[]>([]);
  const [isFavorite, setIsFavorite] = useState(false);

  const { user } = useAuth();
  const userData = useUserDataStore((state) => state.userData);

  /* Googleでログインしていない場合にお気に入りボタンを押すとログイン画面へ */
  if (userData.uid === '') {
    return (
      <div className="w-full">
        <label htmlFor="modal_recommend" className="btn btn-ghost">
        <Image
            src={"/icons/BookMark.svg"}
            alt={comedian.name}
            width={30}
            height={30}
          />
        </label>
        <input type="checkbox" id="modal_recommend" className="modal-toggle" />
        <div className="modal" role="dialog">
          <div className="modal-box bg-white mb-8 flex flex-col justify-center items-center">
            <p className="p-4">お気に入り機能を使う場合はGoogleアカウントでのログインが必要です。</p>
            <Link href={'/application/login'} className={`btn btn-lg btn-block btn-primary text-base text-black text-center font-bold rounded-lg hover:translate-y-0.5 transform transition border-none bg-gray-100 w-[210px]`}>
              <p>ログインページへ</p>
            </Link>
          </div>
          <label className="modal-backdrop" htmlFor="modal_recommend">Close</label>
        </div>
      </div>
    );
  }

  async function handleSave() {
    await axios.post(`/api/favorite?userid=${userData.uid}`, comedian);
    setIsFavorite(true);
  }

  async function handleDelete() {
    await axios.patch(
      `/api/favorite?userid=${userData.uid}&comedianId=${comedian.id}`,
      comedian
    );
    setIsFavorite(false);
  }

  useEffect(() => {
    (async () => {
      const { data } = await axios.get(`/api/favorite?userid=${userData.uid}`);
      const comedians = data;
      //console.log(comedian.id);
      //console.log(comedians);
      setComedians(data);

      setIsFavorite(
        comedians.some(
          (comedian_data: Comedian) => comedian_data.id === comedian.id
        )
      );
      console.log(isFavorite);
    })();
  }, []);

  return (
    <div className="flex">
      {isFavorite ? (
        <button className="btn btn-ghost" onClick={handleDelete}>
          <Image
            src="/icons/BookMark_fill.svg"
            alt={comedian.name}
            width={30}
            height={30}
          />
        </button>
      ) : (
        <button className="btn btn-ghost" onClick={handleSave}>
          <Image
            src={"/icons/BookMark.svg"}
            alt={comedian.name}
            width={30}
            height={30}
          />
        </button>
      )}
    </div>
  );
}
```

`useUserDataStore`, `useAuth` はFirebaseの認証情報関連についてで、`Comedian`は単純にComedian型でデータを呼び出す際に使用しています。


## 紹介機能 (おすすめ芸人紹介の仕組み)
芸人さん達の傾向の類似性を出す必要があり、私が独自の項目を考えました。これは芸人個人として全てのネタに共通するものと漫才にだけ評価すべきもの、コントでだけ評価すべきもの、ピン芸の時だけ評価すべきものがそれぞれあると考えています。例えば漫才については正統派なネタをするか破天荒なネタをするかを評価することが出来ます。正統派を含めて7項目ほどを11段階(0~10)で評価しています。

類似芸人を求める手段として[**クラスタリング**](https://exawizards.com/column/article/ai/clustering/)と[**マンハッタン距離**](https://ja.wikipedia.org/wiki/マンハッタン距離)を用いることにしました。

今回はCSVでのコードを紹介します。
まずクラスタリングを行います。k近傍法でクラスタリングしました。そのコードは以下の通りです。
クラスタリング結果をテーブルに格納するための効率のいいコードがわかる方がいればぜひ教えてください。

``` clustering.py
import pandas as pd
from sklearn import KMeans

# 今回はCSVを読み込んでCSVに保存する書き方
talkers = pd.read_csv('./talk.csv', encoding='shift-jis')

# 欠損値を削除 kMeansは欠損値があるとエラー
talk_drop = talkers.dropna()

n_clusters = 25 # クラスタ数
col_start = 2 # 評価項目の一番左([評価a])
col = 7 # 項目数

# クラスタリング(k近傍)
kmeans = KMeans(n_clusters=n_clusters, max_iter=200)
# クラスタリング結果はリスト
cluster = kmeans.fit_predict(talk_drop.values[:,col_start:col_start+col-1])
talk_drop['cluster'] = cluster # リストをDFに格納


# クラスタリング結果を元々のDFに格納(talkers)
# 欠損値があった箇所は -1 とする
t_cluster = [-1] * len(talkers['コンビ名'])
talkers['cluster'] = t_cluster

for i in range(len(t_cluster)):
    for j in range(len(t_cluster)):
        # talk_dropのj行目が存在しないとエラーが出る
        try:
            if talkers.loc[i,'コンビ名'] == talk_drop.loc[j,'コンビ名']:
                talkers.loc[i,'cluster'] = talk_drop.loc[j,'cluster']
        except:
            continue
```
ここで作成したクラスタリング結果を用いて以下のコードでマンハッタン距離を求めます。
まず、クラスタリングできた芸人について、その芸人のクラスタと同じクラスタの芸人を探します。同じクラスタの芸人を見つけるとマンハッタン距離を求めて、その距離が最小の芸人のインデックスを保存しておき、そのインデックスの芸人IDを元の芸人の"top1"列に保存します。

``` manhattan.py
for i in range(len(talkers['ID'])):
    
    # クラスタが-1(未測定)でなければ類似芸人を探す
    if talkers.loc[i,'cluster']!=-1:
        idx = 0 # マンハッタン距離が最小の芸人のインデックス
        # 全ての項目が真逆ならば項目数*10の差が出るのでそれ以上に指定
        min_manhattan = col * 10 + 100 
        
        for j in range(len(talkers['ID'])):
            # 違う芸人かつクラスタは同じ芸人との類似性を計算
            if(talkers.loc[i,'ID']!=talkers.loc[j,'ID'] and talkers.loc[i,'cluster']==talkers.loc[j,'cluster']):
                
                # マンハッタン距離を計算
                manhattan = 0
                for k in range(2,col+2):
                    manhattan += abs(talkers.iloc[i,k]-talkers.iloc[j,k])
                
                # 最小値を更新したら、その芸人のインデックスも記録
                if manhattan<min_manhattan:
                    min_manhattan = manhattan
                    idx = j
        
        # マンハッタン距離が最小となった芸人のIDを格納
        talkers.loc[i,'top1'] = talkers.loc[idx,'ID']
    
    # クラスタが-1のものはエラー
    else:
        talkers.loc[i,'top1'] = 'XXXXXXXXXX'
```

単純に評価項目のマンハッタン距離だけでも良かったのですが、処理速度を少し早められるので、クラスタリングも使うことにしました。また、クラスタリングのおかげで評価項目の精度もある程度測ることが出来ました(同じクラスタにいる芸人が似ていないかを判断する)。

あとはこの結果を保存してDBにしたあと、クライアント側からSQLでアクセスすれば、類似芸人のIDと名前を求められます。
``` sql
SELECT id, name FROM all_comedians 
WHERE ID = 
  (SELECT top1 FROM duo_talk 
  WHERE name = '[入力芸人名]');
```

## データベース
現在のSQLite3のDBファイルには芸人データ全体(Comedian型になりうる)を入れている"all_comedians"と漫才師の評価項目を格納している"duo_talk"の二つのテーブルを保存しています。今回の主キーは芸人IDを格納した"ID"列としています。"duo_talk"テーブル内の"top1"列は類似芸人の芸人IDを格納しています。DBサーバの計算時間短縮とアクセス数を減らしたいので、類似芸人の産出は開発者側で予め求めたものを"top1"列に保存しています。

all_comediansテーブル
|ID|name|kana|year|...|SNS|info|
|---|---|---|---|---|---|---|
|YST1300001|ニッポンの社長|にっぽんのしゃちょう|2013|...|0|???|
|YST0900002|ダイタク|だいたく|2009|...|0|???|
|・|・|・|・|・|・|・|・|

正直、このテーブル内に情報を詰め込みすぎて冗長に感じるので、テーブルを分ける必要はあると思います。
例えば、SNSのリンクだけのテーブルや芸風の情報だけのテーブルなどにわけてから結合するように改善しようと思います。

duo_talkテーブル
|ID|name|[評価a]|...|cluster|top1|
|---|---|---|---|---|---|
YST1300001|ニッポンの社長|4|...|6|YST0800048|
YST0900002|ダイタク|9|...|5|YST1300018|
|・|・|・|・|・|・|

今後はコント、ピン芸についても評価項目を格納したテーブルを作成する予定です。



## 参考. URLから画像を表示する
この機能は現時点では使用していませんが、作ったものなので紹介します。
芸人さんの写真を表示しないとサイト全体の華やかさが激減するので、画像表示機能は欲しいと考えました。しかし、1000組を超え、今後増え続ける芸人データに対して、全芸人の画像をストレージに保存するのは現実的ではありません。そこで、**各事務所の芸人個別ページから宣材写真を引用する**ことを考えました。
そこで問題となるのが**画像サイズ**です。事務所ごとに画像サイズが違うので、Webデザインの面で不便でした。
URLから画像を取得して、サイズを統一サイズに変更してから返す関数を画像処理が得意なPythonで実装しました。[Web上の画像をファイルに残さずOpenCV用に取り込む](https://qiita.com/lt900ed/items/891e162a5a1091bae912)を参考にして、画像を取得します。

``` url_to_img.py
import requests
import tempfile
import cv2
import os

def imread_web(url):
    # 画像をリクエストする
    res = requests.get(url)
    img = None
    # Tempfileを作成して即読み込む
    fp = tempfile.NamedTemporaryFile(dir='./', delete=False)
    fp.write(res.content)
    fp.close()
    img = cv2.imread(fp.name)
    os.remove(fp.name)
    return img
```
このようにして取得した画像のリサイズします。基準となるサイズは吉本興業のコンビの宣材写真サイズである`(315, 420)`とします。
``` resize.py
import numpy as np

def resize_img(img):
    # 表示したい画像サイズ
    h_base, w_base = (315, 420)

    # 読み込み画像サイズ
    print('original: ', img.shape[:2])
    h, w = img.shape[:2]
    
    # 画像サイズが違う画像はリサイズ
    if h!=h_base and w!=w_base:
    
        # 基本サイズが縦の方が小さいので縦に合わせるように倍率を縮小
        # 左右の倍率を変えないでリサイズすることが大事
        out_img = cv2.resize(img, None, None, h_base/h, h_base/h)

        r_w = out_img.shape[1] # リサイズ後の横幅
        
        # 横サイズが大きい場合はトリミング
        if r_w > w_base:
            out_img = out_img[:,int((r_w-w_base)/2):int((r_w+w_base)/2)]
        
        # 横サイズが小さい場合は白で埋める
        elif r_w < w_base:
            tmp = out_img         
            out_img = np.uint8(np.array([[[255]*3]*w_base]*h_base)) # 白で埋める
            out_img[:,int((w_base-r_w)/2):int((w_base+r_w)/2)] = tmp
            
    # 画像サイズが同じ場合はスルー 
    else:
        out_img = img

    # リサイズ後の画像サイズ
    print('resize: ',out_img.shape[:2])

    # 表示のためにRGBに変換
    rgb_img = cv2.cvtColor(out_img, cv2.COLOR_BGR2RGB)

    return rgb_img
```

## さいごに
Tendonの技術的な内容について記しました。認証機能などについても、もう少し勉強して、時間があれば書いてみたいなと思っています。

ここまで読んでいただきありがとうございました。

# 参考資料
+ [Web上の画像をファイルに残さずOpenCV用に取り込む](https://qiita.com/lt900ed/items/891e162a5a1091bae912)
+ [【OpenCV-Python】resize(画像の拡大縮小)](https://imagingsolution.net/program/python/opencv-python/opencv_resize_image/)