楽して技術書典 5 のサークルさんを探したい

[技術書典 5](https://techbookfest.org/event/tbf05)、楽しみですね。
どんなサークルさんに出会えるのか、もう楽しみで夜も 8 時間くらいしか眠れないです。

とっても楽しみなのですが当日会場で迷わないためにも事前チェックは欠かせません。
技術書典 5 のサイトにはサークルチェックリストという便利機能があるのでこれを利用するわけですね。
チェックするサークルさんの数を雑に数えてみたら

```javascript:console
> $$('.circle-list > li').length
471
```

って言われました。すごい。 (2018/10/02 現在)

もちろん時間があれば全部 1 つずつ見ていくわけですが、もう少し何とかならないかと devtools で眺めているとサークルさんのデータは API で一覧を取得することができるようになっていました。

なので一覧データに入っているもので単語検索してみた、というのがこの記事の趣旨です。

## できたもの

Node.js でこんな感じの CLI を作ってみました。
あいまい検索で関連していそうなサークルさんの名前と詳細ページの URL が一覧できます。

![tbf05.gif](https://qiita-image-store.s3.amazonaws.com/0/2580/bf059eb3-4595-8699-7691-926ea216d371.gif)

## 少し解説

サークルさん一覧データの中に「ジャンル詳細」というフリーテキスト的なフィールドがあったので、そのテキストを [kuromoji.js](https://github.com/takuyaa/kuromoji.js) で分解して、それをソースに [Fuse.js](https://github.com/krisk/Fuse) で検索しています。
コンソールのインターフェースは [inquirer.js](https://github.com/SBoudrias/Inquirer.js/) + [inquirer-autocomplete-prompt](https://github.com/mokkabonna/inquirer-autocomplete-prompt) プラグインでカッコ良い感じにしてみました。
いやぁ便利なものばかりでありがたいなぁ。

## CLI のソース

ソースをそのまま実行できるように作ろうと思ったのですが、思った以上に ECMAScript Modules に体がなれてしまっていたので [rollup.js](https://rollupjs.org/guide/en) でバンドルしたものを実行するようにしました。
Node.js v8+ くらいで実行できるはず。

以下のファイルを同じディレクトリに配置して

1. `$ yarn install` で必要なモジュールをインストール
2. `$ yarn build` でビルドして
3. `$ yarn start` で実行してください

ソースはちゃんと整理してないので読みづらいです。先に謝罪します。ごめんなさい☆

```javascript:main.js
import fs from 'fs'
import { URL, URLSearchParams } from 'url'
import { resolve as resolvePath } from 'path'
import { Agent } from 'https'
import fetch from 'node-fetch'
import kuromoji from 'kuromoji'
import Fuse from 'fuse.js'
import inquirer from 'inquirer'
import autocomplete from 'inquirer-autocomplete-prompt'

const wait = ms =>
  new Promise(resolve => {
    setTimeout(() => resolve(true), ms)
  })

const agent = new Agent({
  keepAlive: true,
})

const fetchCircles = cursor => {
  const api = new URL('https://techbookfest.org/api/circle')
  const params = new URLSearchParams(
    'eventID=tbf05&visibility=site&limit=100&onlyAdoption=true'
  )
  if (cursor) {
    params.set('cursor', cursor)
  }
  api.search = params

  return fetch(api.href, { agent }).then(res => (res.ok ? res.json() : {}))
}

const buildTokenizer = () =>
  new Promise((resolve, reject) => {
    const dicPath = resolvePath(__dirname, './node_modules/kuromoji/dict/')
    const builder = kuromoji.builder({ dicPath })

    builder.build((err, tokenizer) => {
      if (!err) {
        resolve(tokenizer)
      } else {
        reject(null)
      }
    })
  })

const buildCircleData = list =>
  list.reduce((data, circle) => {
    const { id, name } = circle
    data[id] = name
    return data
  }, {})

const buildWords = async list => {
  const isNoun = word => word.pos === '名詞'
  const isTwoOrMore = word => word.surface_form.length > 1

  const words = {}
  const tokenizer = await buildTokenizer()

  // サークル一覧を解析
  list.forEach(item => {
    const { id, genreFreeFormat } = item

    // ジャンル詳細のテキストを分解して名詞だけ抜き出して処理
    tokenizer
      .tokenize(genreFreeFormat)
      .filter(isNoun)
      .filter(isTwoOrMore)
      .forEach(noun => {
        const { surface_form } = noun
        const key = surface_form.toLowerCase()

        // 単語が未登録なら初期化
        if (!words[key]) {
          words[key] = {
            circles: [],
          }
        }

        // 単語を使っているサークル ID を紐付ける
        const circles = words[key].circles
        if (!circles.includes(id)) {
          circles.push(id)
        }
      })
  })

  return words
}

const main = async () => {
  console.log('Start fetching data...')

  // サークル一覧のデータを集める
  const CIRCLE_JSON = './circles.json'
  let circleList = []

  if (fs.existsSync(CIRCLE_JSON)) {
    circleList = require(CIRCLE_JSON)
  } else {
    // ローカルにデータがなければ API を使って拝借する
    let next
    do {
      const json = await fetchCircles(next)
      const { cursor, list } = json
      next = cursor
      Array.prototype.push.apply(circleList, list)
    } while (next && (await wait(800)))

    fs.writeFileSync(CIRCLE_JSON, JSON.stringify(circleList, null, 2))
    console.log(`Saved ${CIRCLE_JSON}`)
  }

  console.log('Done.', circleList.length)

  const circles = buildCircleData(circleList)
  const words = await buildWords(circleList)

  // 単語データから使われている順にソートした検索用データを作る
  const ranking = Object.entries(words)
    .map(([tag, value]) => ({
      tag,
      circles: value.circles,
    }))
    .sort((a, b) => b.circles.length - a.circles.length)

  // あいまい検索のセットアップ
  const fuse = new Fuse(ranking, {
    shouldSort: true,
    tokenize: true,
    matchAllTokens: true,
    minMatchCharLength: 2,
    keys: ['tag'],
    id: 'circles',
  })

  // プロンプトに表示する検索結果
  const CIRCLE_PATH = 'https://techbookfest.org/event/tbf05/circle/'
  const source = (answers, input) =>
    new Promise(resolve => {
      setTimeout(() => {
        const results = fuse.search(input || '')
        const formatted = results.map(
          (id, index) => `${index}. ${circles[id]} - ${CIRCLE_PATH}${id}`
        )
        resolve(formatted)
      }, 500)
    })

  // 検索プロンプトを表示する
  inquirer.registerPrompt('autocomplete', autocomplete)
  inquirer.prompt([
    {
      type: 'autocomplete',
      name: 'fuzzySearch',
      message: 'Type any text to find:',
      source,
      pageSize: 16,
    },
  ])
}

main()
```

<details><summary>その他必要なもの</summary><div>

```javascript:.eslintrc.js
module.exports = {
  root: true,
  parserOptions: {
    ecmaVersion: 2017,
    sourceType: 'module',
  },
  extends: ['plugin:prettier/recommended'],
}
```

```javascript:.prettierrc.js
module.exports = {
  singleQuote: true,
  semi: false,
  trailingComma: 'es5',
}
```

```json:package.json
{
  "name": "tbf05",
  "version": "1.0.0",
  "main": "bundle.js",
  "license": "MIT",
  "scripts": {
    "build": "rollup -c",
    "dev": "rollup -c -w",
    "start": "node bundle.js"
  },
  "dependencies": {
    "fuse.js": "^3.2.1",
    "inquirer": "^6.2.0",
    "inquirer-autocomplete-prompt": "^1.0.1",
    "kuromoji": "^0.1.2",
    "node-fetch": "^2.2.0"
  },
  "devDependencies": {
    "eslint": "^5.6.0",
    "eslint-config-prettier": "^3.1.0",
    "eslint-plugin-prettier": "^2.7.0",
    "prettier": "1.14.3",
    "rollup": "^0.66.2",
    "rollup-plugin-commonjs": "^9.1.8",
    "rollup-plugin-json": "^3.1.0",
    "rollup-plugin-node-resolve": "^3.4.0"
  }
}
```

```javascript:rollup.config.js
import resolve from 'rollup-plugin-node-resolve'
import commonjs from 'rollup-plugin-commonjs'
import json from 'rollup-plugin-json'

export default {
  input: 'main.js',
  output: {
    file: 'bundle.js',
    format: 'cjs',
  },
  plugins: [resolve(), commonjs(), json()],
  external: id => !id.startsWith('./'),
}
```

</div></detail>

## 最後に

正直、フリーフォーマットのテキスト内をあいまい検索するだけなので精度はあまり良くありませんでした。
そりゃそうですよね＞＜
スコアの高い上位のものはまあ当たっているのですが、下位になると求めている結果と違うものになっちゃっています。
このあたりは Fuse.js の設定をいじれば多少は改善できそうです。

サークルさんのデータは詳細 API もあるのでそっちも使えばもっと的確なサジェストができるのですが、 400 以上あるサークルさんのデータをざっとさらって集めるのは~~だるい~~サーバの負荷とか気になったのでやりませんでした。

もっと良い感じにジャンル分けして検索もバッチリなものを誰か作ってくださいおねがいします。