null部の部誌

プログラミングの話とか色々。

GASでGoogleドライブの更新をSlack通知・Trelloで管理

こんにちは。

以下の記事で紹介した,Googleドライブの更新をSlackに通知するbotのおはなしです。
pizzacat83.hatenablog.com

ソースコードはこちら。
github.com

概要

更新通知

特定のフォルダの子孫に更新があると,Slackにこんな感じの通知が来ます。(隠していますが,タイトル部分にはファイルのフルパスが表示されています。)
f:id:pizzacat83:20190302230608p:plain:w400

ファイル1つをattachment1つで表すため,100個以上のファイルが一気に更新されるとSlackの仕様で弾かれます。そもそも100行以上のメッセージが投稿されるとスクロールが大変です。Slackは「attachmentは20件までにしようね!」とドキュメントに書いているので,20件を超える場合はファイルとして投稿することによって折りたたみ表示されるようにします。プレーンテキストなのでSlackフリープランのストレージ制限はあまり気になりません。
f:id:pizzacat83:20190306204625p:plain:w400

Trello管理

生きていると時々ファイルが削除されることがあります。Googleドライブの共有設定で削除を禁止することはできないので,クラス共有フォルダ内で削除されるべきでないファイルが削除された場合,復元をする必要があります。消すやついないだろと思うかもしれませんが過去5回くらい消えました。ローカルのを消そうとして,とか。

復元するだけなら管理人(私)が雑に再アップロードすれば良いのですが,管理人はバカなので削除の通知が来ても「/* 後で対応する */」と言って結局忘却します。5回消えて3回忘却しました。ヒューマンエラーは環境の改善で解決されるべきです。

そこで,ファイル削除(復元ToDo)をTrelloで管理することにしました。SlackをTrelloと連携させた上で,ファイルが削除された際,ToDoリストにカードを追加します。 f:id:pizzacat83:20190306212637p:plain:w400

削除された日時の3日後が期限として設定されており,チェックリストには削除されたファイル一覧が表示されます(ただし数が多い場合はチェックリストではなく削除アイテム一覧のテキストファイルへのリンクが添付されます)。
f:id:pizzacat83:20190306210128p:plain

botがSlackにカードへのリンクを投稿し,Trello botが展開してくれます。これによってSlackからの操作が容易になります。
f:id:pizzacat83:20190306210422p:plain

それでも管理人は忘れそうなので,期限を過ぎてToDoに残っているカードはbotが自動で復元します。また,期限は過ぎていないが面倒なので自動修復してほしい場合は,Autofixというボードに移動しておくと自動復元してくれます。

自動復元しなくて良い場合はDoneリストにカードを移動すればよく,「今週中に修正版をアップロードするため消した」のような場合はカードの期限を週末に変更するだけで済みます。以上の操作は全てSlack上から/trelloコマンドで実行できます。

うちのクラスの管理人は私だけで,Trelloのアカウントを持っている人も少ないので今のところこのボードは私しか使っていませんが,管理人が複数いる場合は担当者にassignすることもできますし,削除してしまった人にメンションしてカードのコメント欄で対応を協議することもできるでしょう。

実装

フォルダ下の更新情報を得る

Drive Activity API

かつては全てのファイルの最終更新日時を再帰的に調べていたんですが,ファイル総数が2000近くあって実行制限の6分以内に終わらなくなりました。Drive Activity APIを用いれば,そんなことしなくても一瞬で更新履歴を取得できます。しかも更新された時刻だけでなく,追加・移動・編集・共有設定変更など更新の詳細も取得できます。エウレカ

ある時刻からの全ての更新履歴を得るコードはこんな感じです。

ルートフォルダのIDを指定する際,クエリのancestorNameに指定する値は類とフォルダのIDにitems/を前置したものであることに注意してください。更新履歴が多い場合はnextPageTokenという値がもらえるので,それを用いてもう一度リクエストすると続きを見ることができます。また,consolidationStrategy{legacy: {}}にすると,同じユーザによる同時期の複数ファイルの追加などを1つにグルーピングしてくれます。

これで得た更新履歴を雑に整形してSlackに投げます。グルーピングされたまとまりの中には個々のActionが入っていて,それをattachmentにする衝動に駆られますが,例えば「ファイル1つを追加」という大きなまとまりの中にはファイル追加・移動・共有権限設定などのActionが含まれていて,それらをいちいち別個のattachmentにするのはあまり好ましくありません。そこで個々のActionは無視して,マクロ的な変更の内容,変更されたファイル一覧,変更したユーザ一覧,変更日時を見ることにします。

変更されたファイルについて,APIはファイルのIDと名前を教えてくれますが,フルパスはわかりません。ファイル名だけでは大抵何のファイルかわからないので,フルパスを取得します。

フルパス取得

そもそもGoogleドライブのファイルシステムは木ではありません。フォルダは複数の子を持つことができ,複数の親をもつことができます。よって,フルパスという概念はありません。

そこで,ファイルの親を再帰的に辿っていき,クラス共有フォルダにたどり着いたルートをフルパスとみなすことにします。ルートは複数あるかもしれませんがそれは気にしません。最短ルートであるかどうかもどうでもいいので雑にDFSします。あるフォルダからルートフォルダへの経路が既知である場合はそれを流用できるので,pathsというMapに記憶させます。

ちなみにMapはGASに実装されていないので,babelしましょう。

ユーザ名取得

Drive Activity APIは変更をしたユーザの情報も教えてくれるのですが,people/1234567890みたいなIDしか教えてくれないので誰のことだかさっぱりです。そこでPeople APIを使います。このgetメソッドを呼ぶと,ユーザの表示名を取得することができます(undefinedの場合もあります)。

undefined結構多いので,対応表を自分で用意したほうが早いかもしれませんね。うちのクラスメイトは全員クラスGoogleグループに登録されていて,それを利用して名前やメールアドレス(→Slackアカウントと紐付け)とか取得できないかなあと思ったんですが,現在@groups.google.comのメンバーを取得できるAPIはないそうです。悲しい。

フォルダを無視する

クラス共有フォルダには1時間ごとに更新されるログとかも入っていて,その更新通知が来ても困ります。無視したいフォルダを登録しておき,あるファイルの親フォルダが全て無視対象である場合に無視する,というルールで更新情報を無視します。

Trello連携

全般

Trello APIはドキュメント通りにリクエストを投げるだけですが,適当にラップします。

カード作成

Card作成→(Checklist追加→Checklistの中身を追加)or URLを添付→SlackにCardのurlを投稿
という流れになります。一つ一つリクエストを投げれば良いです。

自動復元の際にカードと削除されたファイルのID一覧との対応関係がわかるように,スクリプトのプロパティに登録します。

自動復元

カード作成時に記憶しておいたファイルのID一覧を取得して,各IDについて復元を実行します。具体的には,ファイルのコピーを作成し,元のファイルの親フォルダ全てにコピーを追加します。

フォルダの復元はGoogleドライブのファイルシステムがややこしくて,うまくやる方法がまだ思いついていないので,未実装です。助けてください

あとがき

実行時間が6分を超える旧仕様と比べた高速化を目指して大幅改修をしたわけですが,改修後も変更が多いと実行に30秒とかかかっていて,もう少し高速化したいです。高速化できそうな箇所はいくつかあるので,ぼちぼちやっていきます。

Slackで$…$や$$…$$を数式にするSlash Command

どうもぴざきゃっとです。

今回は,以下の記事で紹介したクラスSlackの機能の1つである数式入力の話をします。

pizzacat83.hatenablog.com

botソースコードはこちら

github.com

概要

/formulaに続いて$…$や$$…$$で数式を囲んだメッセージを入力すると、その部分が数式画像で置き換えられたメッセージが届きます。

f:id:pizzacat83:20190306162603p:plain:w500

以前は$$で囲まずに数式だけを書く、という仕様だったので、一応それにも対応しています。

f:id:pizzacat83:20190306162148p:plain:w300

数式をSlackで書けるようにするbotは既に存在していて,実際参考にしているんですが,このように$...$や$$...$$を含む文章に対応しているものはないんじゃないですかね。

実装

入力テキスト→数式画像入りメッセージ

このbotはGASで動かす関係でGAS特有の関数があったりしてNode.js等に転用しにくいものが多いですが、この部分に関してはNode.jsでも動くと思います。ただしTypeScriptなので適宜コンパイルしてください。

流れとしては、

  1. 正規表現でメッセージを数式部分を取得
  2. 各数式とそれに先行するテキストをattachment化

という感じです。

正規表現/([^]*?)((\$\$?)[^]+?\3)/gで,これを用いるとabc$def$ghi$$jkl$$mnoからabc$def$ghi$$jkl$$を抽出できます。

各マッチに対して,数式画像のattachmentを生成します。プレーンテキストから数式画像を生成するのにGoogle Charts APIを使います。リンク先見るとわかるんですがこのAPIはdeprecatedで,いつ使えなくなるかわからないんですが,代案が見つからなかったので当面はこれを使います。クエリパラメータにencodeURIComponentした文字列を書いてあげるとそれが画像へのURLになります。

このURLをattachmentのimage_urlに指定するだけで,Slackに画像付きメッセージを投稿することができます。なので,URLから画像をfetchするような処理は不要です。

また,attachmentのpretextには数式の前の文字列(abc$def$abcの部分)を指定します。

全てのマッチを処理した後はabc$def$ghi$$jkl$$mnomnoが残るので,mnoをpretextとしimage_urlを持たないattachmentを最後に追加します。なお最後のattachmentはスマホから見るとtextやimage_urlがないことによる謎の点f:id:pizzacat83:20190306152507p:plainが出るので,colorを#ffffffにすると見栄えが良いです。

数式画像入りメッセージ→レスポンスJSON

この部分はGAS特有です。

リクエストはPOSTでやってくるので,応答するためのdoPost(e)という関数を作ります。やってくるリクエストは/formulaだけではなく、他のSlash CommandsやEvent APIなども来るので,こんな感じで処理しています。

どのコマンドのリクエストなのかがcommandというパラメータに入っているので,コマンドの文字列に対応する関数をslackCommandsというオブジェクトに格納しておき,slackCommands['command']みたいな感じで呼び出すようにしています。関数の実行結果を受け取ったら,JSON文字列に変換してレスポンスを返します。

ちなみにGASWebEventSlackCommandParamsというinterfaceを用意しているので,コードを書くときに補完が効いて快適です。

使い心地

理系クラスなので当然数式を書きたいわけですが,TeXに慣れていなくてよくミスった数式が表示されてしまい,なんども投稿し直すことが多いです。プレビュー機能があると良さそうですね。ephemeralレスポンスとボタンで作れそうです。

クラスSlackを作った

クラスLINEってだいたいどこにでもありますよね。そこで連絡事項が流れたり,質問が飛んだり。んで,今回はLINEグループの代わりにSlackワークスペースを作りました,という記事です。

うちのクラスSlackは決して成功したモデルではないと思うんですが,私が開発した個々の機能自体は別に悪い子じゃないと思うので,成功した方々がぜひこの記事を参考にしてより良いワークスペースにすることができたらとても嬉しいです。

botソースコードを公開しているので,良ければ利用してください。

github.com

作った理由

サークル2つ入ったら2つとも部員間のコミュニケーションにSlackを使っていて,便利そうだなーと思いました。クラスでは駒場祭やオリ合宿などいろいろなイベントが同時に進行していくので,チャンネルを分けて話題が流れないようにできるととても嬉しいです。あと,「駒場祭グル作りました!参加する人は個チャください!」→個チャ→招待→参加承認という流れが非常に鬱陶しく,参加以前の投稿は見えないので人が集まり切るまで投稿ができないなど,LINEのこの辺の不便さがとても嫌いです。

また,当時クラスLINEはひんやりしていた一方で一部メンバーからなる雑談グルは毎日わいわいしていたので,クラス全体に雑談の場を設ければもっと仲良くなれるんじゃないかな,というのもあります。

チャンネル一覧

f:id:pizzacat83:20181203111606p:plain:w200
チャンネル一覧

長いですねー。Tree viewにしてほしいとの要望があったのでChrome拡張でも作れば良さそうなんですが,まだ作れていません。

命名規則は,

  • 科目ごとの勉強チャンネル: st-*
  • 雑談チャンネル: z-
  • それ以外: 出来るだけ英数字で始まる適当な名前

英数字から始めるのは,変換不要にすることで入力を楽にするためです。後に日本語が来る分には自動補完で解決できるので。

komabasaiとかorientationとかはどのクラスにもありそうなチャンネルですが,それ以外のチャンネルについては後で詳しく説明していきたいと思います。

基本設定

基本的にデフォルトでOKです。ワークスペース参加時に自動で入るチャンネルがデフォルトではrandomgeneralだけなので,komabasaiとかorientationとかも追加してあげましょう。

また,おやすみモードはデフォルトでオフにしておくことをおすすめします。おやすみモードを知らない人からすれば「通知がこないよ😠」となってしまうし,いざという時に@channelで人が集まらないです。デフォルトでオフにしておいて,おやすみモードを使いたい人は自分で設定しましょう,という運用が良いと私は思います。

カスタム絵文字

Slackの醍醐味ですね。現在117個登録されています。大体の感情表現には困らないです。ほとんどが文字列絵文字で,私の性格を反映してか煽るタイプの絵文字がとても多いです。

ここは絵文字一覧をお見せすべきところなんでしょうが,教員の絵文字とかお見苦しい内輪ネタとかが多くてちょっとね。ご想像にお任せします。

formula: 数式入力

数式は人権なので,/formulaというコマンドで数式を書けるようにします。

f:id:pizzacat83:20190302223521p:plain:w400

$…$や$$…$$を含む文章を投げるといい感じにしてくれます。

f:id:pizzacat83:20190302224206p:plain:w500

色々できます。

f:id:pizzacat83:20190302225046p:plain:w200

f:id:pizzacat83:20190302225255p:plain:w200

技術的な話は別記事に書きました。大した分量ではないけど。

question: チャンネルメンバーにメンション

特定のチャンネルのメンバー全員にメンションするコマンド/questionです。 f:id:pizzacat83:20190302225858p:plain:w300

チャンネルの外から担当者に呼びかける時に使えます。

announcementとannouncement-sub

全体の色々を書いたので,個々のチャンネルの話に移りたいと思います。

announcement

announcementは色々なお知らせが流れるチャンネルです。

宿題リマインダー

これまで手動で/remindしていたんですが,最近Googleカレンダーに移行しています。直前と朝と週の初めに自動でリマインドしてくれるの嬉しいですね。前日にあればもっと嬉しいのだけれど,作れってことですかね。はい。

教務課からのお知らせ

前期教養の学生が定期的に見るよう強要される掲示板がいくつかあって,「教務課からのお知らせ」はログイン不要でアクセスできるので,これの更新情報を流します。RSSがあればいいんですが残念ながらないので,作ります。Feed43というサービスを使うと,お好きなWebページをスクレイピングしてRSSフィードにすることができます。

feed43.com

あとは/feedでsubscribeすればOKです。

ただ,教務課からのお知らせには1年向けと2年向けがあり,違う学年のお知らせは鬱陶しいので排除します。教務課は「1年向け」「2年向け」というページを用意してるんですが,漏れていることが発覚したので自分で1年向けを抽出します。だめじゃん。Feed43にそんな便利機能はないので,IFTTTと組み合わせます。

まず,RSSのitem, link, content欄をそれぞれタイトル,リンク,対象学年を表す画像へのリンクにします。設定済みのRSSフィードこちら

次に,IFTTT PlatformでRSS Feed→SlackのAppletを作ります。IFTTTじゃなくてIFTTT PlatformなのはFilterを差し込むためです。入力項目は適当に埋めましょう。で,contentに書いてある画像へのURLから対象学年を判断するFilterを書きます。抽出がへたくそでいろんなゴミがくっついてきたときのために,完全一致ではなく部分一致でやります。あと仕様が変わったときに,間違って投稿されることはあっても間違ってスキップされないようなコードを心がけます。

おしまい。

announcement-sub

announcement-subは諸施設のTwitterなど,広報の中に大事な情報が混じってるものを流すところです。重要そうなものは私が手動でannouncementに貼ります。全員参加のチャンネルではありません。現在は図書館と学生自治会TwitterがIFTTTによって流れます。また,ログイン必要なUTASの掲示一覧も流れるようにしようとしたんですが,未だにバグらせていて悲しいです。

slack意見箱

これは改善の要望を書くところです。改善といっても「この絵文字作って」レベルのものから色々あります。

f:id:pizzacat83:20181203140437p:plain:w400

近いうちにやると決めたことは✋で,進行中は✍️で,終わったことは✔️でリアクションします。has::emoji:で検索すると各段階のタスクの一覧が出るので便利。他のワークスペースでも使ってる簡易なタスク管理方法です。複数人でやるときはassignされた人のアイコンもリアクションしておくと良さそうです。

匿名でも投稿できるようにGoogleフォームを置いてます。送信すると自動でSlackに投稿されます。たった7行です。

drive-log

クラスのGoogleドライブフォルダがあるんですが,そこのファイル更新履歴を流します。技術的な話は別記事にします。

f:id:pizzacat83:20190302230608p:plain:w300

seashore

私が所属するサークルTSGのSlackには #sandbox というチャンネルがあって,24時間以上経過したメッセージが自動で削除されます。ピン留めされたものは削除されません。各種botを試すのに使ったり,bot開発時に試しに投稿を流したりするのに使います。ちなみに #seashore という名前は砂浜に描いた文字が定期的に波でかき消される様子から来ています。

slackbotレスポンス

ワークスペースにデフォルトでいる@USLACKBOTことslackbotは,開発不要で「Aと言われたらBと返す」機能があります。同じAに複数のBを登録するとランダムで返すので,ガチャが作れます。

  • サイコロ:「サイコロ」って言うと1〜6が出る
  • ルーレット:「ルーレット」って言うと0〜9が出る
  • 評定ガチャ:「評定ガチャ」って言うと不可〜優上が出る
  • 逆評定ガチャ:「逆評定ガチャ」って言うと大鬼〜大仏が出る
  • 必修ガチャ:「必修ガチャ」って言うと必修科目がランダムで出る
  • 絵文字ガチャ:カスタム絵文字がランダムで出る

また,「一日一独作文」と言うとドイツ語の和文独訳問題の和文が出るガチャも作ったんですが,教科書の進度に合わせて手動で追加するのが面倒で最初の方の問題しか出ません。こんな使い方もできます。

本日のユータスくん

前期課程のWebサイトには 本日のユータスくんというコーナーがあります。おそらく毎日欠かさず「教務課からのお知らせ」を見に行かせるためのモチベーションなんでしょうが,#announcement のおかげで見る機会がなくなってしまったので,自動で #seashore に投稿されるようにしています。実装は適当にスクレイピングするだけです。

f:id:pizzacat83:20190302232207p:plain:w300

あとがき

Slackはbotの開発にはうってつけです。みんなもSlackのbotを作ろう。

claspでGASのローカル開発&mock不要テストができるテンプレを作った

まとめると

  • GASをローカルで開発しようと思った
  • UrlFetchAppとかを含む関数のテストがローカルでは動かない
  • GAS上でテストコードを動かしてしまえ!
  • 用のテンプレを作った

きっかけ

Google Apps Scriptで動かしているslackbotがいるんですが,ブラウザ上のエディタで適当にコーディングしているせいでlint噛ませてない難読コードになっていたので,ローカルでの開発に移行しようと思いました。これならgitでバージョン管理とかもできるし。

最近はclaspという便利なものがあります。ローカルからGASにpush, pull, runなどができる,Google純正の便利なCLIツール。

github.com

おまけにTypeScriptでGASの開発をするテンプレまで作ってくださった方がいます。

github.com

最高ですね。で,薮みたいなコードをTSに書き換えてテストを書いて実行してみたところ,

Cannot find name 'UrlFetchApp'

まあそりゃそうか。そりゃそうだけども…つらい。

これに対する一般的な対処方法としては,

  1. mockを書く
  2. GAS上で動くテストツールを使う

という感じになると思います。前者にはgas-localというmock作成支援パッケージがあり,後者はQUnitGasTというものがあります。

GasT便利そうですね(今更)考えた当時は深い理由もなくJest使いたかったんですよ。

ということで,テストコードをGAS上で実行しローカルのJestでテストするためのテンプレを作りました。

概要

先ほど紹介したウルトラ便利テンプレgas-clasp-starterのフォークです。

github.com

百聞は一見に如かず。

↑このコードに対するテストコードが↓

で,evalOnGAS内のfunctionがGAS上でevalされます。

evalされます。

GAS上でevalできるんですよ。ちゃんとUrlFetchAppとか自分で作った関数とかも実行できます。が,セキュリティあんまり知らないのでevalという文字列がすでに怖い。

ので,テスト環境と本番環境でプロジェクトを別にします。evalする関数が本番環境にはプッシュされないようにします。テスト環境ではAPIなどの実行権限を「自分のみ」にします。念には念を入れ,リクエストに含めるverify tokenがGASに持たせたハッシュと一致しなければ失敗するようにします。

ちなみにテスト自体はローカルで動いているので,当然evalOnGASの結果をローカルで遊ぶことができます。GAS上では動かないパッケージも利用できます。実際,今作ってるプロジェクトの画像URLを返すべき関数のテストでrequest使ってたりします。

使い方

READMEにだいたい書いてあります。その気になったら日本語でここに書きます。

仕組み

scriptIdが書かれた.clasp.jsonをデプロイ時に.clasp.test.jsonまたは.clasp.release.jsonで上書きします。これでデプロイ先を変更できます。

evalする関数がテスト環境だけに実装されるために,ifdef-loaderというwebpackのプラグインを使います。Cプリプロセッサの#ifのノリで,コードをまるっと無視できます。そして#ifの条件に環境変数GAS_DEBUGを設定します。

この辺をいい感じにやってくれるのがdeploy.test.shとdeploy.release.shです。npm scriptsにしなかったのはそうすると若干遅い(気がする)ため,watchして常に最新のコードをテスト環境にデプロイするのに向かないと思ったからです。

ハマったところ

claspがANSI escape sequenceを使ってシェルアニメを表示してくれるんですが,execFileするとstdoutにANSI escape sequenceがびっしり。真面目に処理するのが面倒だったので雑に最後の削除命令の後だけ正規表現で抽出しました。

余談

evalやっぱり怖い。セキュリティの強い人なんかアドバイスあったら教えてください。

UTAUの周波数表ファイルをPythonで読む

UTAU音源にくっついてくる.frqファイルのデータをPythonで読むコードです。

.frqのフォーマットが書かれたツイートを見つけたので,それをそのまま実装しました。形式が解析済みって嬉しいですよね。これが無かったら労力が桁違い。

何となく分かるものについて各データの説明をすると,

  • key_frequency: 平均のピッチ的なもの(恐らく外れ値は除外されているだろう)175くらいだったらF3かなーとか分かる
  • datacount: frequencyおよびamplitudeの配列の長さ
  • frequency: 周波数

あとはよくわかりません。

structモジュールの仕様でchar[]は文字列ではなくバイト列として返ってくるので,必要に応じてdecodeしてください。

Wikipediaの他言語の記事名を得るPythonスクリプト

最近「あれ,この用語英語でなんていうんだ…?」(専門用語なので和英辞書は望み薄)という状況が何回かあって,その度にWikipediaの記事を見つけて英語版のタイトルを見る,とかやってたんですが,めんどくさいのでシュッとできるようにしました。

こんなツール絶対既出だと思ったんですけど軽くググっても出ないので自分で書きました。APIのドキュメント読んで適切なクエリを投げておしまい。WikipediaAPIを扱えるパッケージは存在しますが,普段venvでいろんな環境を飛び回っていることを考えて標準ライブラリでやります。

これを適当なところに置いて,chmodして,パス通して,alias wikipedica=wikipedica.pyしたらなんかそれっぽくなります。

$ wikipedica 百科事典
Encyclopedia

多言語対応。

$ wikipedica 百科事典 ja de
Enzyklopädie

なお例外処理は放棄しました。読めばわかるじゃん。

エディタ画面から離れることなく辞書を引けるのはとても嬉しいですね。英語力の低さをひしひしと感じます。それでは。

語彙力ゲーム「弓箭」であそぶ

この記事はTSG Advent Calendar 2018の12日目として書かれたものです。昨日はmoratorium08さんによる「 TSG live AI コンテストの内容と感想、盤面生成に関する妄想など」でした。

さてお久しぶりです、pizzacat83です。今年からTSGというサークルに入りまして,Advent Calendarになんか書こうということで本当はクラスで使ってるSlackのお話をするつもりだったんですが,その記事を半分くらい書いたところでこのゲームを思いついたのでこっちのお話をします。TSGはコミュニケーションツールとしてSlackを使っているのですが,そこで語彙力で戦うゲーム「弓箭」を遊べるbotを作りました。

弓箭とは

お題の単語に対して“近い”単語を回答すると,その単語とお題の類似度と,その単語のレア度(コーパス中の出現頻度の低さ)に応じた点数をもらえるゲームです。つまり,お題に似ていて,かつあまり使われない単語を答えた人の勝ちです。

ゲームの名前「弓箭(きゅうせん)」は弓矢,弓矢を扱う武士,弓矢を用いた戦いなどを意味する単語*1です。「戦い」を意味する単語で語彙力が高そうなやつ,といういかにも語彙力のなさそうな理由で名付けました。英語表記はとりあえずvocabwarにしていますが,Blitzkrieg*2とかにしようかな。

ちなみにこれは私がオリジナルで考えたゲームではなくて,英単語についてほとんど同じルール(ただし類似度とレア度ではなく類似度と解答スピードを評価)のゲームがあるんですが,ググり力がなくて見つかりませんでした。黒い背景に水色の文字みたいな見た目だったと思うんですが,有識者教えてください。

ゲームの様子

Slackに「弓箭」と書き込むと,お題の単語を10個提案してくれます。単語を一つ選ぶと回答を書き込むフェーズに入ります。

f:id:pizzacat83:20181211124939p:plain:w400
弓箭スタート

お題選ぶ必要なくない?という指摘があったので,「即弓箭」と書き込むと提案なしでランダムにお題が決まるようになっています。運が悪いと知らない単語が出てきてたほいやになります。

f:id:pizzacat83:20181212170025p:plain:w400
わからない

ちなみに形容詞・形容動詞・副詞のみが出題されるようになっています。というのも,名詞は類義語が作りづらいことがありそうなのと,たほいやが名詞ばっかりなのでそちらに一任したいという気持ちがあります。あと日本語は細かいニュアンスを動詞の違いではなく修飾語によって表現することが多い(ex. glimpse「ちらりと見る」 stare「じっと見る」) (接頭語もあるけど)というのもあります。

回答を書き込みます。未知語は弾かれます。(私が👍しているように見えるのはbotトークンを使っていないからです)

f:id:pizzacat83:20181212170132p:plain:w400
弓箭で遊ぶ人々

30秒経過すると結果発表です。参加人数の2倍のダミーを混ぜて,類似度が高い順に上位1/3が正の得点を得ます。

f:id:pizzacat83:20181212171450p:plain:w400
結果発表

元がスピード勝負のゲームだったのでこちらも時間を厳しく設定しているのですが,得点が期待できる単語を考えている間に結果発表されてしまうこともしばしば。難しい単語を考えるとなるともう少し長めにした方がいいかもしれませんね。

ちなみにダミーはレア度の高い単語が多めに出るようにしていて,結果発表にコトバンクから取ってきた単語の意味も記載しているので,結果発表の後はダミーを眺めて次の戦いに備え語彙力を高めることができます。たほいやのダミー選択肢を覚えてるこわい人多いし,弓箭のダミー単語もどんどん覚えられていくんじゃないですかね。

実装

たほいやは博多市さんが一晩で作ったらしいですが,私は強くないので弓箭をn晩かけて作りました。全体的にたほいやに似ているので,大枠のコードはほとんど一緒です。弓箭の本質は類似度とレア度なので,その辺の話を軽く。

類似度はword2vecを使っているんですが,分かち書きされたコーパスが必要なのでWikipedia全文データを取ってきて分かち書きをします。で,このときついでに単語の出現回数を数えます。Wikipediaの全文データはXMLになっているので、wp2txtでプレーンテキストにしてあげます。

$ sudo gem install wp2txt
$ wp2txt --input-file ./jawiki-latest-pages-articles.xml

まあこれ以外にも同様のツール色々あるので探してください。

んでこれを分かち書きしつつ単語の出現頻度を見ます。TSGのslackbotはNode.jsで動いていて,tashibotという別のbotがkuromoji.jsを使っていたので私も使ってみたんですが,なんせ巨大ファイルを入出力するので(おそらく出力の仕方が下手で)メモリをバカ食いして死にました。よく考えたらNode.jsやkuromoji.jsでやる必要がないことに気づいたのでPythonで適当に書きます。こっちは常識的なメモリを食べていました。

これで分かち書きされたコーパスとレア度(出現頻度の逆数の常用対数)を得られました。さてword2vecしようということですが,Node.jsからword2vecできるパッケージが存在します。

www.npmjs.com

ということで早速学習させてみたんですが今度はCPUとメモリをバカ食いして死にました。小さなコーパスでやってみたら学習結果の出力形式が単語 0.000000 0.000000 ...という感じの分かりやすいテキストファイルになっていて,よく考えたらNode.jsでやる必要がないことに気づいたのでPythonで適当に書きます。gensimは良い子なのでメモリは500MBくらい,2時間ほどで学習終わったかな。

from gensim.models import word2vec
data = word2vec.Text8Corpus("corpus/wiki_wakachi.txt")
model = word2vec.Word2Vec(data, size=300)
model.save("corpus/wiki_wakati.model") # モデルの保存
model.wv.save_word2vec_format("corpus/wiki.wv.txt") # node.jsでも読める単語ベクトル一覧のテキストファイル

Node.jsで読めるように出力関数を自分で書こうとしたらgensimが用意してくれてました。本当に良い子。ただテキストファイルで単語ベクトル一覧を保存する場合,拡張子は.txtにすべきです。それ以外だとNode.jsのword2vecパッケージがバイナリとして解釈してよくわからん感じになります(これにn時間溶かした)。ただ中身を見る必要がないならファイルサイズも小さくなるしバイナリにした方が良いと思います。この場合は最下行が次のようになります。

model.wv.save_word2vec_format("corpus/wiki.wv.txt", binary=True)

これでレア度と類似度を求めることができます。

ちなみに「ゲームの様子」で書いた通りダミーはレア度の高い単語が多めに出るようになっています。要するに重み付き乱択です。重み付き乱択って色々アルゴリズムがあって見るたびに賢いなーっていつも思います。私は走査1回で済ませるやつと前処理して二分探索するやつが好きです。が,今回は重み付き非復元抽出なのと前処理をする気にならない(ただでさえメモリ食べてるので…)のとで,愚直に「重みなしで1つ抽出してレア度に応じた確率で棄却する」という方針でやっています。数学的に確かめてないんですけどこれで重みに応じて選択されるんですかね。

ちなみに高得点を得るコツが分かってくると,人間の尺度ではなくword2vecの尺度で「近い」単語を考えるようになります。弓箭を極めて歩くword2vecになろう。

今後

現状では得点が保存されないんですが実装した方がいいですかね。それからさらなる語彙力の向上のために,結果発表に模範解答(類似度そこそこ高く,レア度が高い)を含めようかなとか思ってます。まあこれは賛否両論かもしれませんが。

あとWikipediaの語彙力だけでは少し物足りないので青空文庫も学習させようと思ってるんですが,gensimで追加学習しようとすると怒られます。

model.train(corpus_file="corpus/aozora/wakachi.txt", epochs=model.epochs, total_examples=12345, total_words=1234567)
---------------------------------------------------------------------------
ModuleNotFoundError                       Traceback (most recent call last)
(中略)
*/venv/lib/python3.7/site-packages/gensim/models/base_any2vec.py in _train_epoch_corpusfile(self, corpus_file, cur_epoch, total_examples, total_words, **kwargs)
    403             raise ValueError("total_words must be provided alongside corpus_file argument.")
    404 
--> 405         from gensim.models.word2vec_corpusfile import CythonVocab
    406         from gensim.models.fasttext import FastText
    407         cython_vocab = CythonVocab(self.wv, hs=self.hs, fasttext=isinstance(self, FastText))

ModuleNotFoundError: No module named 'gensim.models.word2vec_corpusfile'

古いバージョン使ったら行けたりするんですかね。有識者教えてください。最初から学習し直すのも一手か。

あとはそもそも日本語ってあんまり弓箭向いてないんですよね。外来語の取り込み方が上手なので,類義語が簡単に思いついてしまってあんまり面白くないです(編集距離で加点しようかな)。英語とかだといろんな言語からの借用語が統合されないので類義語の綴りが全然違っていて,もう少し楽しくなるんですけどね。ただ「英語版弓箭」は最初の方に書いた通り弓箭の元ネタとして既存(名前を忘れたけど)なので,どうせやるなら情報科学の論文のみをコーパスとする弓箭とか面白いんじゃないですかね。実用的だし。

ということで,TSG Advent Calendar 2018 12日目でした。明日はfiordさんによる「TSG LIVE2!の感想・反省等を適当に書いたポエム - アルゴリズマーの備忘録」です。

*1:ちなみに「弓箭」と「戦争」「戦い」の類似度はそんなに高くなくて、「弓矢」との類似度はとても高いです。弓矢の意味で使われてることがほとんどということですね

*2:電撃戦のこと。ドイツ語より。