MediaWikiを巡回して記事名の読みがなをソートキーに追加するBotを作った

# リポジトリ
https://github.com/kn1cht/defaultSortBot

# ねらい
[宇宙科学総合研究会(LYNCS)](http://www.lyncs-keio.net/)という団体で、団体内の知識共有・蓄積に[MediaWiki](https://www.mediawiki.org/)を使っています。Wikipediaのような巨大サイトでも動いているだけあって非常に使いやすいのですが、細かい部分で小回りが利かないことも出てきました。

Wikiに記事が増えてくると、カテゴリを整備したくなってきます。ところが、肝心のページ一覧があまり見やすくないのです。

![category-view.png](https://qiita-image-store.s3.amazonaws.com/0/150557/d91a8967-52b8-fbce-22b8-20692d8cd806.png)

画像の通り、**記事名の一文字目からUnicode順**でソート・グループ化されます。漢字圏には厳しい仕様ですね。このままでは、日本語タイトルの記事を一覧にすると**一文字目の漢字に従ってグループが大量にできてしまいます**。

ただし、MediaWikiにはソートキーをユーザ側で指定する機能が用意されています。記事内に `{{DEFAULTSORT: そおときい}}` などと記述すれば、記事名の代わりに指定した文字列を使ってソートしてくれるのです。

とはいえ既存記事をポチポチ編集してデフォルトソートを追加するのは骨が折れます(Wikipediaは人力だそうです。すごい……)。また、今後の新規記事に、忘れずにデフォルトソートを設定してもらうのはおそらく無理でしょう。

そこで、MediaWiki APIを使って記事を巡回し、**記事名のよみがなを自動生成して追加**するBotをnode.jsで作りました。

# 環境
- node.js v8.1.3
- npm v5.0.3
- MediaWiki v1.26.3

# 方針

## MediaWiki API
- [macbre/nodemw](https://github.com/macbre/nodemw)

MediaWikiには、外部からWikiを操作できる[API](https://www.mediawiki.org/wiki/API:Main_page/ja)が付属しています。これを使えば記事一覧の取得や編集など一通り行うことができます。

MediaWikiのAPIをnode.jsから使う前例がないかなと思って探したところ、npmにライブラリが[いくつか](https://www.npmjs.com/search?q=mediawiki)ありました。必要な機能が揃っていたので、[nodemw](https://github.com/macbre/nodemw)を使用しています。[実際のWikiでも運用されていて](https://github.com/macbre/nodemw#where-its-used)信頼できそうです。

```js
const nodemw = require('nodemw');
// ...
const client = new nodemw({
  server: '{Wikiのあるサーバ}',
  path: '{api.phpのあるパス}',
});
```

のようにすると使えます。

nodemwのメソッドは全てコールバック方式になっており、そのまま使うと簡単に地獄が出現するので注意です。Node v8の新機能`util.promisify()`でPromise化してから使うことにします。初めメソッド内で参照する`this`が変わってしまい動かなかったのですが、こちらの記事を参考に`this`をbindするとうまく行きました。

- [Node.js8になって util.promisify が利用できるようになったのでメモ](http://blog.manaten.net/entry/util-promisify)

```js
const util = require('util');
// ...
await util.promisify(bot.logIn).bind(bot) (config.username, config.password); // login
```

なお、ユーザーネームやパスワードといった外に出せない情報は[node-config](https://github.com/lorenwest/node-config)を使って外部ファイルから読み込むようにしました。

## 形態素解析
- [azu/kuromojin](https://github.com/azu/kuromojin)
- [walling/unorm](https://github.com/walling/unorm)

デフォルトソートのないページを見つけたら、タイトルの読みがなを生成しなければなりません。**形態素解析ライブラリ**を使えば、漢字の読みを得ることができます。

```js
const tokenize = require('kuromojin').tokenize;
const unorm = require('unorm');
// ...
const title = unorm.nfkc(pageTitle); // unicode normalization
const tokens = await tokenize(title);
let reading = tokens.reduce((res, token) => {
  return (token.reading) ? (res + token.reading) : (res + token.surface_form);
}, '');
```

表記ゆれに備えるため、事前にunormによるUnicode正規化も挟みました。元々カタカナや数字だった部分の`reading`は空になってしまうので、ない場合は代わりに`surface_form`を読みとして追加します。

## Wikipediaのルールに合わせる
- [hakatashi/japanese.js](https://github.com/hakatashi/japanese.js)

Wikipediaのソートキーのつけ方には[ルール](https://ja.wikipedia.org/wiki/Wikipedia:%E3%82%AB%E3%83%86%E3%82%B4%E3%83%AA%E3%81%AE%E6%96%B9%E9%87%9D#.E3.82.BD.E3.83.BC.E3.83.88.E3.82.AD.E3.83.BC)が存在します。人名などに関する細かい規定を除けば、以下のようになります。

- **ひらがな**で読み仮名を記述する
- **濁音・半濁音**は清音に(e.g. が→か)
- **拗音**は直音に(e.g. ちょくおん→ちよくおん)
- **長音**は母音に(e.g. そーときー→そおときい)
- アルファベットや数字はそのまま記載する

形態素解析の段階では読みはカタカナなので、hakatashi氏の[japanese.js](https://github.com/hakatashi/japanese.js)でひらがなに変換します。

```js
const japanese = require('japanese');
// ...
reading = japanese.hiraganize(reading);
reading = normalizeForDefaultSort(reading);
```

`normalizeForDefaultSort()`でWikipediaルール準拠に修正していきます。
まずは濁音/半濁音/拗音。文字コードや正規表現でかっこよくやる方法が特に思いつかず、地道に全通り書き出してreplaceしました。

```js
const defaultSortDictionary = {
  'ぁ' : 'あ', 'ぃ' : 'い', 'ぅ' : 'う', 'ぇ' : 'え', 'ぉ' : 'お',
  'が' : 'か', 'ぎ' : 'き', 'ぐ' : 'く', 'げ' : 'け', 'ご' : 'こ',
  'ざ' : 'さ', 'じ' : 'し', 'ず' : 'す', 'ぜ' : 'せ', 'ぞ' : 'そ',
  'だ' : 'た', 'ぢ' : 'ち', 'っ' : 'つ', 'づ' : 'つ', 'で' : 'て',
  'ど' : 'と', 'ば' : 'は', 'ぱ' : 'は', 'び' : 'ひ', 'ぴ' : 'ひ',
  'ぶ' : 'ふ', 'ぷ' : 'ふ', 'べ' : 'へ', 'ぺ' : 'へ', 'ぼ' : 'ほ',
  'ぽ' : 'ほ', 'ゃ' : 'や', 'ゅ' : 'ゆ', 'ょ' : 'よ', 'ゎ' : 'わ',
  'ゐ' : 'い', 'ゑ' : 'え', 'ゔ' : 'う'
};

for(const key in defaultSortDictionary) {
  if(!defaultSortDictionary.hasOwnProperty(key)) { continue; }
  str = str.replace(new RegExp(key, 'g'), defaultSortDictionary[key]);
}
```

長音はもう少し面倒で、`'ー'`の直前の文字の母音が何か判定しなければなりません。これもうまい方法が浮かばないので書き出しました。

```js
const cyoonDictionary = {
  'あ' : ['あ', 'か', 'さ', 'た', 'な', 'は', 'ま', 'や', 'ら', 'わ'],
  'い' : ['い', 'き', 'し', 'ち', 'に', 'ひ', 'み', 'り', 'ゐ'],
  'う' : ['う', 'く', 'す', 'つ', 'ぬ', 'ふ', 'む', 'ゆ', 'る'],
  'え' : ['え', 'け', 'せ', 'て', 'ね', 'へ', 'め', 'れ', 'ゑ'],
  'お' : ['お', 'こ', 'そ', 'と', 'の', 'ほ', 'も', 'よ', 'ろ', 'を']
};
// ...
str = str.replace(/.[\u30FC\u2010-\u2015\u2212\uFF70-]/g, (match) => {
  const firstLetter = match.slice(0, 1);
  const result = Object.keys(cyoonDictionary).reduce((res, key)  => {
    return (cyoonDictionary[key].indexOf(firstLetter) >= 0) ? key : res;
  }, null);
  return result ? (firstLetter + result) : match;
});
return str;
```
`firstLetter`が`'ー'`直前の1文字です。これが`cyoonDictionary`の5つの配列のどれかに入っていれば、`result`に`'あ'〜'お'`のいずれかが返ります。

「2017-07-07」のように、長音でない長音符やハイフンの使い方もあり得ます。この場合は`result`がnullになり、reduce最後の三項演算子で元のマッチ文字列が採用されます。

# 実装

メインの処理の流れはこんな感じです。

- `bot.logIn()`でログイン(ログイントークン取得)
- 名前空間(後述)ごとに全ページ取得(`bot.getPagesInNamespace()`)
    * ページごとに本文取得(`bot.getArticle()`)
        + `/{{DEFAULTSORT:.*}}/`を含むページはスキップ
        + Unicode正規化・形態素解析・読みがな取得・ひらがな化
        + ソートキーのルール通りに変換
        + デフォルトソートを末尾に追加して編集(`bot.edit()`)

ログインはトークン取得・トークン送信の二回に分ける、編集の際は別途「編集トークン」を取得するなどの面倒な部分は全てnodemwがやってくれます。

```js:main.js
if (require.main === module) { main(); }

function main() {
  (async() => {
    await util.promisify(bot.logIn).bind(bot) (config.username, config.password); // login to get permisson
    for(const ns of config.namespaces) {
      const allpage = await util.promisify(bot.getPagesInNamespace).bind(bot) (ns.id); // get page data as JSON
      for(const page of allpage) {
        let pageData = await util.promisify(bot.getArticle).bind(bot) (page.title);
        const pageTitle = (page.title.indexOf(ns.prefix + ':') >= 0) ? page.title.substr(ns.prefix.length + 1) : page.title;
        let editSummary = 'Bot: Add DEFAULTSORT ';

        if(/\{\{DEFAULTSORT:.*\}\}/.test(pageData)) { continue; } // skip if page already have DEFAULTSORT
        const title = unorm.nfkc(pageTitle); // unicode normalization
        const tokens = await tokenize(title);
        let reading = tokens.reduce((res, token) => {
          return (token.reading) ? (res + token.reading) : (res + token.surface_form);
        }, '');
        reading = japanese.hiraganize(reading);
        reading = normalizeForDefaultSort(reading);

        pageData += '\n{{DEFAULTSORT: ' + reading + '}}';
        editSummary += reading;
        await util.promisify(bot.edit).bind(bot) (page.title, pageData, editSummary);
        console.info('Edited ' + page.title + '/ ' + editSummary);
      }
    }
  })().catch((err) => {
    console.error(err);
  });
}
```

## 名前空間
MediaWikiのページは「**名前空間**」で分類されています。カテゴリは「カテゴリ」名前空間、ファイルは「ファイル」名前空間といった具合です。
カテゴリを上位のカテゴリに入れて一覧にすることもあるので、カテゴリにもデフォルトソートを設置することにします。

標準以外の名前空間に属する記事には、タイトルの前に「カテゴリ:」「ファイル:」といった**接頭辞**がつきます。タイトルを取得するとこれもくっついてくるため、読みがなにする場合は取り除く必要があります。

```yaml:default.yaml
namespaces: # namespace id and prefix to process
  -
    id: 0
    prefix: ""
  -
    id: 14
    prefix: "カテゴリ"
```

node-configの設定ファイルに、処理したい名前空間を配列で書いておきます。
`id`は名前空間を識別する番号で、APIに渡すのに使います。
`prefix`が除去対象となる接頭辞です。

```js:main.js
const pageTitle = (page.title.indexOf(ns.prefix + ':') >= 0) ? page.title.substr(ns.prefix.length + 1) : page.title;
```

# 実行例

自分のアカウントで実行すると更新履歴が埋まってしまうので、Bot用に新しくアカウントを発行します。

![account.png](https://qiita-image-store.s3.amazonaws.com/0/150557/1a479986-dfd2-6a19-bb0f-ded7456ddf06.png)

次に、作ったアカウントを「利用者権限を管理」のページでbotグループに追加します。これで更新履歴にデフォルトでは表示されなくなりました。

![bot.png](https://qiita-image-store.s3.amazonaws.com/0/150557/f8b308b9-bed5-f271-c756-29fce95b26f4.png)

設定ファイルにホスト・api.phpのパス・Botのユーザ名・パスワードを記入してから実行してみます。

```bash
$ node main.js 
Edited はいプロ/ Bot: Add DEFAULTSORT はいふろ
Edited メインページ/ Bot: Add DEFAULTSORT めいんへえし
Edited 二郎からのセイク/ Bot: Add DEFAULTSORT しろうからのせいく
Edited 五千兆円欲しい!/ Bot: Add DEFAULTSORT こせんちようえんほしい!
Edited 人の金で焼肉が食べたい/ Bot: Add DEFAULTSORT ひとのきんてやきにくかたへたい
Edited 山路を登りながら/ Bot: Add DEFAULTSORT やましをのほりなから
Edited 森鴎外/ Bot: Add DEFAULTSORT もりおうかい
Edited 魔剤/ Bot: Add DEFAULTSORT まさい
Edited カテゴリ:Hoge/ Bot: Add DEFAULTSORT Hoge
```

`/`以降は、編集のサマリーとしてWikiの更新履歴にも残ります。所々間違えているものの、ソートに使う分には最初の数文字しか見ないことがほとんどですからそれほど気になりません。

Botの編集履歴にも、結果がきちんと残っていますね。

![log.png](https://qiita-image-store.s3.amazonaws.com/0/150557/fb46c4ae-19d7-3f8b-7e41-86e2be6de803.png)

カテゴリ一覧を見ると、ちゃんとひらがなでグループ分けされるようになっています。この例では記事が少なくあまり見た目が変わっていませんが、さらに記事数が増えても50音順に並んでグループ分けされるので、大幅に記事を探しやすくなりそうです。

![category-view-after.png](https://qiita-image-store.s3.amazonaws.com/0/150557/8be9e317-dd28-9555-076a-82e363663151.png)

# Tips
## 参照読み込み時の注意

Mediawikiには、`{{}}`でページ名などを囲むことでそのページの中身を呼び出せる「[参照読み込み](https://www.mediawiki.org/wiki/Help:Transclusion/ja)」という機能があります。テンプレート機能などで広く使われていますが、呼び出し先ページにもデフォルトソートが設定されていると、「**ソートキーが前のソートキーを上書きしている**」旨の警告が出てしまいます。

他のページに読み込まれるページではそもそも使わないか、空のデフォルトソートを設定しておくことで回避できるようです。

```
{{DEFAULTSORT: そおときい}} <- 他ページから読み込むと警告が出る
{{DEFAULTSORT: }} <- 警告が出ない
```

## 定期的に実行する

今後の新規ページにもデフォルトソートを追加するために、Botを**一定時間おきに稼働させる**ことにします。

書き方が楽そうだったので[node-cron](https://github.com/merencia/node-cron)を使いました。別ファイルに`main.js`を読み込んでcronを設定します(例では1日おき)。

```js:schedule.js
'use strict';

const cron = require('node-cron');
const main = require('./main');

cron.schedule('0 0 * * *', () => { main(); });
```

[forever](https://github.com/foreverjs/forever)を使ってデーモン化します。

```bash
sudo npm i -g forever
forever start schedule.js
```

これで1日以内にできたページにはデフォルトソートが設定されるようになりました。