hubotにレンズおみくじを装着してみた

# **はじめに**

みなさん、おみくじは好きですか？
信じるも信じないも自由ですが、僕は結構好きです。

そして、 **レンズ** も好きです。
そう、 **レンズおみくじ** を作ることができたら、楽しいのではないか...。そう、思いました。

あとSlackも好きなので当然botにしてみようなんて思ったわけです。

<img src="https://qiita-image-store.s3.amazonaws.com/0/45253/d5cf72bc-edf4-30ed-aa2d-1f23a09137b1.png" width=30%>

# **おみくじを作ってみる**

とりあえず普通におみくじを作ってみましょう。

```
# おみくじ
omikuji = ["神吉", "大吉", "吉", "中吉", "小吉", "末吉", "凶", "大凶"]
gobi = ["です。", "やぞ", "ｗｗｗ", "ですしおすし", "でございます。", "安定"]

# 0から引数未満のランダムな整数を得る
random = (n) ->
  Math.floor(Math.random() * n)

module.exports = (robot) ->

  # 運勢を占う
  robot.respond /おみくじ/i, (msg) ->
    msg.reply omikuji[random(omikuji.length)] + gobi[random(gobi.length)]
```

こう書くと、

|![WS000259.png](https://qiita-image-store.s3.amazonaws.com/0/45253/b67b21e5-4b8f-18b1-4435-7c66b9381b12.png)|
|:-:|

こんな感じになるわけです。これはこれで楽しい。

ちなみにわざわざrandomとったりしてますが、単純に配列から1コ選ぶだけなら以下のように `msg.random` 使うのが楽です。

- **[Hubot-scriptsのmsg.randomを学ぶ](https://qiita.com/daxanya1/items/4d7a8c94fa0756c97b5d)**

今回は単純にランダムな数をあとあと使いたいのでこんな感じにしています。

# **レンズおみくじを作ってみる**

さて、それではいよいよレンズおみくじを作ってみます。ｺﾞｸﾘ。
以下のようなものを作りたいと思います。

- **キーワードに反応して動く**
- **メーカー、スペック問わずランダムにレンズを1つ選ぶ**
- **選んだレンズをもとに運勢を決める**
- **選んだレンズの作例を添え** ~~、物欲を煽~~ **る**
- **選んだレンズの参考価格を添え** ~~、物欲を煽~~ **る**

それでは、順にいきましょう。

## **情報源を探す**

割と一番大事です。

- メーカー問わずレンズが載っている
- レンズの作例が載っている
- レンズの価格が載っている

価格はさておき、作例がいっぱいあるサイトはそれほど多くないため、結局これを主軸に探すことになります。

- **[PHOTOHITO](https://photohito.com/)**
- **[フォトヨドバシ](http://photo.yodobashi.com/)**
- **[GANREF](https://ganref.jp/)**
- **[マップカメラ](https://www.mapcamera.com/)**

どれも素晴らしい、いやもはや凶悪と言っていいほどに恐ろしいサイトです。
不用意にここを眺めていると数日後に家にレンズが届くという怪奇現象が起こることもしばしば。

今回は **[PHOTOHITO](https://photohito.com/)** を使うことにしました。
作例が豊富で、サイトもスッキリしているので情報が取りやすくて今回の目的にはよいですね。

## **処理を考える**

最終的にはCoffeeScriptに落としていくのですが、一旦処理の流れを掴む意味でシェルで徐々に処理を作っていってみます。
基本的にはシェル芸みたいなやり方でHTMLの中から望みの情報を得ていきます。

### **レンズメーカーを選ぶ**

さて、レンズ一覧ページを見てみましょう。

https://photohito.com/lens/

ここに望みのメーカー一覧のようなものがありますので、抜き出してみましょう。

```
$ curl -s https://photohito.com/lens/ | grep '<a href="/lens/brands/[a-z]*/">.*</a>' | sed -E 's@.*<a href="(.*)">.*</a>.*@\1@g'
/lens/brands/canon/
/lens/brands/lensbaby/
/lens/brands/samyang/
/lens/brands/sony/
(略)
```

`/lens/brands/` という表現を手がかりにすれば割と簡単にレンズメーカー一覧が得られます。

### **レンズを選ぶ**

さて、続いてはとあるメーカーからレンズを1つ選びます。
CANONだと思って探してみましょう。

```
$ curl -s https://photohito.com/lens/brands/canon/ | grep '<a href="/lens/brands/canon' | grep
model | sed -E 's@.*<a href="/lens/brands/canon/model/(.*)/">.*@\1@g' | nkf -Ww --url-input | tr "_" " "
EF50mm F1.8 II
EF24-105mm F4L IS USM
EF-S18-55mm F3.5-5.6 IS
EF100mm F2.8L マクロ IS USM
EF-S55-250mm F4-5.6 IS
EF24-105mm F4L IS USM
(略)
```

少し複雑になった感じでしょうか。
前半の `grep` と `sed` はいつもの感じでいいとして、後半について少し解説です。
後半にある `nkf` ですが、これがないと

```
$ curl -s https://photohito.com/lens/brands/canon/ | grep '<a href="/lens/brands/canon' | grep model | sed -E 's@.*<a href="/lens/brands/canon/model/(.*)/">.*@\1@g' | head
EF50mm%20F1.8%20II
EF24-105mm%20F4L%20IS%20USM
EF-S18-55mm%20F3.5-5.6%20IS
EF100mm%20F2.8L%20%E3%83%9E%E3%82%AF%E3%83%AD%20IS%20USM
EF-S55-250mm%20F4-5.6%20IS
EF24-105mm_F4L_IS_USM
(略)
```

という感じでアレなことになってしまいます。元々URLに相当する部分を抜き出しているので、文字がURLエンコードされているんですね。
それを元々の文字に戻しているのが `nkf -Ww --url-input` というわけです。
その後ろにある `tr "_" " "` はアンダースコアで表現されているスペースを元に戻している、といった感じです。(アンダースコアがレンズ名に入っているレンズは...多分ないでしょうおそらくきっと)

### **レンズスペックを抜き出す**

さて、レンズを選んだら今度はそのレンズを元に運勢を決めなければなりません。
こればっかりはハードコートしたい気持ちもあるんですが、なにしろレンズは膨大ですので、泣く泣く **焦点距離** と **F値** から決めさせたいと思います。
というわけで焦点距離とF値を抜き出してみましょう。

```
$ echo "EF50mm F1.8 II" | sed -E 's/.*[^0-9]+([0-9]*)mm.*[Ff]\/?([0-9][.0-9]*).*/\1 \2/g'
50 1.8
```

もちろん例外もあるんですが、レンズの名前って大体

**[レンズブランド名] [焦点距離]mm F[F値]**

みたいな形をしています。

- EF50mm F1.8 II
- AF-S NIKKOR 105mm f/1.4E ED
- FE 85mm F1.4 GM

などなど。
もちろん例外もありますが、ほとんどのケースにおいて `'s/.*[^0-9]+([0-9]*)mm.*[Ff]\/?([0-9][.0-9]*).*/\1 \2/g'` という正規表現を用いることで、 **焦点距離** と **F値** を得ることができます。

### **レンズスコアを計算する**

それでは、さきほど得たスペック情報を用いてレンズスコアを計算しましょう。
レンズスコアなんていう概念は一般的にないので、自分の気持ちと対話して作りました。

- レンズは **明るいほど** 尊い
- レンズは **短いほど** 尊い
- しかしレンズは **長いほど** 尊い

もっと突き詰めればいい感じの数式に落とせるとは思いますが、一旦以下のようなものにしてみました。

```math
m: 焦点距離 \\
f: F値 \\

をレンズのスペックとしたとき、 \\

レンズスコア: S(m,f) = \left\{
\begin{array}{ll}
\frac{m - 50 + 10}{(f - 0.5)^2} & (m \geq 50) \\
\frac{(50 - m)*2}{(f - 0.5)^2} & (m \lt 50)
\end{array}
\right.
```

標準画角と呼ばれる50mmを中心としてスコアが伸びるようなイメージです。
広角側、望遠側に遠ざかるほどスコアが上がるようにしていますが広角側は「 **血の1mm** 」といわれるほどですので、倍の効果が出るようにしています。もっと細分化して20mm以下なら3倍、4倍などするとよりリアルになるような気がします。
F値の使い方については悩んだのですが、元々平方根的な性質をもつ数字なので **2乗して使う** ことにしています。また、F1.4以上に明るいレンズがより尊くなるよう、意図的にF値をずらして(-0.5)使っています。
そのため、F0.95とかいうような ~~変態~~ レンズは特に尊くなるような計算式になっています。

### **運勢を決める**

あとはレンズスコアに基づいて運勢を決めるだけです。
以下のようにしてみました。

```math
運勢ランク: R = \left\{
\begin{array}{ll}
1: 神吉 & (S \gt 50) \\
2: 大吉 & (50 \geq S \gt 35) \\
3: 吉 & (35 \geq S \gt 20) \\
4: 中吉 & (20 \geq S \gt 10) \\
5: 小吉 & (10 \geq S \gt 5) \\
6: 末吉 & (5 \geq S)
\end{array}
\right.
```

ちなみに上を見ておわかりの通り、 **レンズおみくじに凶はありません** 。
レンズを見て悪い気持ちになることがあろうか、いや、ない。

## **実装してみる**

確認してきた処理を元にCoffeeScriptで実装してみました。まぁ長いので畳んでおきます。

<details><summary>lens_fortune.coffee</summary><div>

```coffeescript
# おみくじ
omikuji = ["神吉", "大吉", "吉", "中吉", "小吉", "末吉", "凶", "大凶"]
gobi = ["です。", "やぞ", "ｗｗｗ", "ですしおすし", "でございます。", "安定"]
fortune_color = ["#585858", "#FA5858", "#FF0000", "#FF8000", "#F7FE2E", "#80FF00", "#0174DF", "#8000FF", "#380B61", "#FF00BF"]

# 改行を含む文字列に対して各行で正規表現マッチを行う
# マッチオブジェクトの入った配列が返される
matchRegex = (str, re) ->
  lines = str.split("\n")
  result = []
  for line in lines
    match = line.match(re)
    if match?
      result.push(match)
  return result

# 0から引数未満のランダムな整数を得る
random = (n) ->
  Math.floor(Math.random() * n)

module.exports = (robot) ->

  # 運勢を占う
  robot.respond /おみくじ/i, (msg) ->
    msg.reply omikuji[random(omikuji.length)] + gobi[random(gobi.length)]

  # レンズを占う
  robot.respond /レンズおみくじ/i, (msg) ->
    base_url = "https://photohito.com"
    lens_url = base_url + "/lens/"
    robot.http(lens_url)
      .get() (err, res, body) ->
        if (err?)
          robot.logger.error(err)
          return msg.send 'レンズおみくじの取得に失敗しました'

        # レンズメーカーを1つ選ぶ
        lens_makers = matchRegex(body, /<a href="(\/lens\/brands\/[a-z]*\/)">(.*)<\/a>/)
        lens_maker = lens_makers[random(lens_makers.length)]
        lens_maker_url = base_url + lens_maker[1]
        lens_maker_name = lens_maker[2]

        robot.http(lens_maker_url)
          .get() (lens_maker_err, lens_maker_res, lens_maker_body) ->
            if (lens_maker_err?)
              robot.logger.error(lens_maker_err)
              return msg.send 'レンズ一覧の取得に失敗しました'

            # レンズを1つ選ぶ
            lenses = matchRegex(lens_maker_body, /<a href="(\/lens\/brands\/.*\/model\/.*\/)">(.*)/)
            lens = lenses[random(lenses.length)]
            lens_url = base_url + lens[1]
            lens_name = decodeURIComponent(lens[1].match(/\/lens\/brands\/.*\/model\/(.*)\//)[1]).replace(/_/g, " ").replace("*", "")

            # レンズスコアを計算する
            lens_info = lens_name.match(/(?:.*[^0-9]+)?([0-9]*)mm.*[Ff]\/?([0-9][.0-9]*).*/)
            if (lens_info)
              lens_dist = lens_info[1]
              lens_focal = lens_info[2]

              if ( lens_dist < 50 )
                lens_score = ( 50 - lens_dist ) * 2 / ( ( lens_focal - 0.5 ) ** 2 )
              else
                lens_score = ( lens_dist - 50 + 10 ) / ( ( lens_focal - 0.5 ) ** 2 )

            robot.http(lens_url)
              .get() (lens_err, lens_res, lens_body) ->
                if (lens_err?)
                  robot.logger.error(lens_err)
                  return msg.send 'レンズの取得に失敗しました'

                # レンズ最安値を抜き出す
                lens_lowest_price = matchRegex(lens_body, /.*<span class="kakakuPrice">.*class="price" target="_blank">&#165;(.*)<\/a><\/span>.*/)[0]

                # レンズ作例を1つ選ぶ
                lens_sample_pages = matchRegex(lens_body, /<a href='(.*)' data-ab-css-background='1'>.*/)
                lens_sample_page = lens_sample_pages[random(lens_sample_pages.length)]
                lens_sample_page_url = base_url.replace("https","http") + lens_sample_page[1]

                robot.http(lens_sample_page_url)
                  .get() (lens_sample_err, lens_sample_res, lens_sample_body) ->
                    if (lens_sample_err?)
                      robot.logger.error(lens_sample_err)
                      return msg.send 'レンズの取得に失敗しました'

                    # レンズ作例URLを取得する
                    lens_sample_url = matchRegex(lens_sample_body, /.*<meta property="og:image" content="(.*)"\/>.*/)[0][1]

                    # レンズスコアを評価
                    if (lens_score)
                      if (lens_score > 50)
                        fortune_rank = 1
                      else if (lens_score > 35)
                        fortune_rank = 2
                      else if (lens_score > 20)
                        fortune_rank = 3
                      else if (lens_score > 10)
                        fortune_rank = 4
                      else if (lens_score > 5)
                        fortune_rank = 5
                      else
                        fortune_rank = 6
                    else
                      fortune_rank = 0

                    # 本文を構成する
                    if (lens_lowest_price)
                      message_body = "今日のレンズは *" + lens_maker_name + "* の *" + lens_name + " (" + lens_lowest_price[1]  + "円～)* で決まり！"
                    else
                      message_body = "今日のレンズは *" + lens_maker_name + "* の *" + lens_name + "* で決まり！"

                    if (lens_score)
                      message_body = message_body + "レンズスコアは *" + lens_score.toFixed(2) + "* ！"

                    # レスポンス
                    data =
                      attachments: [
                        color: fortune_color[fortune_rank]
                        title: omikuji[fortune_rank] + ": " + lens_name
                        title_link: lens_url
                        image_url: lens_sample_url
                        pretext: message_body
                        text: "作例: " + lens_sample_page_url
                        mrkdwn_in: [
                          "text",
                          "pretext"
                        ]
                      ]

                    # hubot-slack4系からこの送り方でよくなった
                    msg.send data
```
</div></details>

特に工夫も何もしていないのでいわゆるコールバック地獄と化しています。
では、動かしてみましょう。

|![WS000261.png](https://qiita-image-store.s3.amazonaws.com/0/45253/be9615c7-1ba0-1b84-7e65-a3d86ef6b005.png)|
|:-:|

いい感じですね。
変わり映えしないので省きましたが、参考価格や作例なんかもいい感じに取ってこれました。

運勢に従ってAttachmentの色を変えているので視認性もばっちりです。

|神吉|大吉|吉|中吉|小吉|末吉|不明|
|:-:|:-:|:-:|:-:|:-:|:-:|:-:|
|<font color="#FA5858">■</font>|<font color="#FF0000">■</font>|<font color="#FF8000">■</font>|<font color="#F7FE2E">■</font>|<font color="#80FF00">■</font>|<font color="#0174DF">■</font>|<font color="#585858">■</font>|

最終的にこのメッセージを作る部分にだけ触れておくと、

```
data =
  attachments: [
    color: fortune_color[fortune_rank]
    title: omikuji[fortune_rank] + ": " + lens_name
    title_link: lens_url
    image_url: lens_sample_url
    pretext: message_body
    text: "作例: " + lens_sample_page_url
    mrkdwn_in: [
      "text",
      "pretext"
    ]
  ]

msg.send data
```

ってのがすべてです。写真のURLを `image_url` に指定すると、いい感じに展開してくれるのが便利ですね。
いろんな情報を抜き出してきて、1つのカタマリにして送ってやるといい感じのメッセージが作れます。
詳しくは以下をどうぞ。

- **[message-attachments - Slack Docs](https://api.slack.com/docs/message-attachments)**

# **課題**

適当に作っているが故にいくつか課題が残っています。

## **ズームレンズの焦点距離**

レンズスコアを計算する際、焦点距離を利用していることは述べた通りですが、ズームレンズの場合の扱いが微妙な感じになっています。
以下の例をご覧ください。

```
$ echo "EF-S55-250mm F4-5.6 IS" | sed -E 's/.*[^0-9]+([0-9]*)mm.*[Ff]\/?([0-9][.0-9]*).*/\1 \2/g'
250 4
```

正規表現を見れば当たり前なのですが、 `mm` の前の数字を捕まえているだけですので、ズームレンズの場合、 **望遠側の焦点距離** を使ってレンズスコアを計算することになります。

### **問題**

そこまで気にするようなことではないのですが、たまに「 **広角側の焦点距離を使ったほうが高スコアとなる** 」場合があります。
例えば24-70mm F2.8のレンズがあったとすると、望遠側を使えば **5.67** 、広角側を使えば **9.83** ってことになります。
こうした、「50mmをまたぐレンズ」や「広角域のズームレンズ」の場合は広角側を使ったほうがハイスコアになるケースがあり、レンズの力を正しく評価できていないような、そんな悲しみがあります。

## **スペックの取得できない表記**

レンズ名に関しては概ね、

**[レンズブランド名] [焦点距離]mm F[F値]**

の形であるとはいいましたが、徳の高い一部のレンズではそうもいきません。

- **Carl Zeiss Touit 1.8/32**
- **SUMMARIT-M F2.4/75mm**

いやー、なんという徳の高さ。ツァイスブランドにライカブランド。
本来であれば見ただけで大吉安定なこのレンズたちですが、悲しいかな今回の実装ではスペックすら満足に取れません。

|![WS000262.png](https://qiita-image-store.s3.amazonaws.com/0/45253/f3ba761f-a6e1-77e1-1214-fa458fceb395.png)|
|:-:|

本当に徳の高いレンズはFが前にくるなんてそんなこと聞いてないですよね。
でも、仕方ないです。それが徳だから。

### **問題**

問題は明確で、「上記の表記にも耐えうる美しい正規表現」か「IF文等で特別扱いする」ことで対応が可能かと思われます。
が、個別対応するのはあまり美しくない...。
1本でより多くのレンズに対応できる美しい正規表現を求めたい気持ちです。

## **レンズブランドを加味して評価できない**

以下をご覧ください。

|![WS000263.png](https://qiita-image-store.s3.amazonaws.com/0/45253/5d3dee5c-a522-19ec-b6cb-8e5de5d0eec0.png)|
|:-:|

|![WS000264.png](https://qiita-image-store.s3.amazonaws.com/0/45253/5e146c17-9fe9-ad73-3c4b-ef2b47ff9f5c.png)|
|:-:|

なんということでしょう、引いたレンズがライカだろうがサムヤンだろうが同じ中吉なのです。
気分としてはライカを引いたら大吉にしたい...。

とはいえ、ブランドという普遍的でないものを評価に入れようとするとどうしてもハードコード的な要素が生まれるので美しくない...。

### **問題**

レンズブランド自体の評価による補正以外では **価格** を使う方法があります。
ただし、今のやり方では価格の取れていないレンズも多く、平等な評価に仕切れないのが難点です。

## **レンズの価値は計れない**

ここにきて身も蓋もないことを言いますが、 **レンズの価値をなにかの尺度で図ろうとすることがそもそもの間違い** です。

**50mm F1.4** のレンズは **85mm F1.4** のレンズよりだめなのでしょうか？
あるいは、 **ライカ** のレンズは **サムヤン** のレンズより優れているのでしょうか？

いずれもわかりません。
すべてのレンズには味があり、それぞれによって生み出される写真の好みが人それぞれである以上、一律にどうこうできるものではなかったのです。

### **問題**

この問題それ自体よりも、このことに気づいていながらレンズスコアなどというものを導入した自分を罪深く思います。

# **終わりに**

でもこの機能は個人的にとても好きです。