【Chrome拡張機能】N予備校の回答チェッカーを作った話

:::note info
2023/12/7 追記
拡張機能のアップデート(バグ修正)に伴って、記事の内容を更新しました。
:::


# すごく雑なきっかけ

私は普段からN予備校を利用している人間です。
もちろんN予備校のことは気に入っています。
ですが、私には一つだけ不満がありました。

> 10問ある問題を解いて解答したときに、もし1問でも間違っていたら、
> 10問とも全てやり直しになってしまう。

この仕様がとにかく嫌で嫌でしょうがなかったんです。

<sub>10問とも全て解き直すのも勉強のうちとか言われそうですが、
このままだとN予備校自体に嫌気が差すレベルで嫌だったので...</sub>

そして、この問題を解決すべく考えたのが、
答え合わせボタンを押すと**誤答がないか確認してくれるコード**でした。

当初は問題を開くたびにコンソールに打とうと思っていたのですが、
それだと面倒なので拡張機能化してみようと思いました。

そうして出来上がったのが↓です。


# 作ったもの

N予備校の問題型教材を開いているときに、「答え合わせ」ボタンを押すだけで間違っている問題一覧が出てくる、N予備校の生徒の味方となっている拡張機能です。

![スクリーンショット 2023-10-26 9.29.44.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3588083/8cdcfd3a-cafe-9548-fbfa-289c2d4c44a8.png)

間違っている問題は全て表示してくれますが、
答えを表示する機能はついてないので、正答は自分で考えてください。
~~つまりダメ出しはしてもどうすればいいのかは教えてくれません。~~

ソースコードはGitHubで公開しているので、
興味が出たら↓のリンクから見てみてください。

https://github.com/nanasi-1/N-Extension


## 公開場所について

Chrome拡張機能といえば[Chromeウェブストア](https://chromewebstore.google.com/?utm_source=ext_app_menu)で公開しそうなものですが、
複雑な事情によりGitHubでソースコードを公開することにしました。

その複雑な事情とは何かというと、Chrome拡張機能を最初に公開するときに必要な、
登録料($5)を支払う方法がないというものです。

あとデプロイとかよくわからない(知識不足)ので無理だと判断しました。
~~この事情が複雑なのかとか知りません。~~


## 注意点

全問解答していなくても上のダイアログは表示できます。
つまり、ボタンが薄くなっている状態で押してもダイアログは出てきます。

また、この選択式以外の問題には対応していません。
テスト型も対応していません。

あと、たまに動かないことがあります。
原因不明です。すみません。
解決方法わかる方、どこでもいいので連絡して頂けると助かります。


# 仕組み

軽く仕組みを解説しようと思います。


## ファイル構成

この拡張機能は、以下のようなファイル構成になっています。

```
N-Extension
├── LICENSE
├── README.md
├── content-script.js
└── manifest.json
```
<sub>※[MacでTreeコマンドを使いディレクトリ構造をテキスト出力する](https://tech.qookie.jp/posts/info-tree-cmd-mac/)を参考にして、
`tree`コマンドをインストールしてみました。</sub>

LICENSEとREADME.mdの2つのファイルを除くと、
コード部分は`content-script.js`と`manifest.json`ということになります。


## manifest.json

コード本体はこちらです。

https://github.com/nanasi-1/N-Extension/blob/main/manifest.json

これは設定などを書くファイルです。
大体こんな感じになってます。

```json:manifest.json
{
  "manifest_version": 3,
    "name": "N予備校系",
    "description": "N予備校の問題式教材の正誤を、解答ボタンを押すことで判定してくれます。",
    "version": "1.0.0",
    "permissions": [
      "activeTab",
      "scripting"
    ],
}
```

ざっくり言ってしまうと、
* `name`は拡張機能の名前
* `description`は拡張機能の説明
* `version`はバージョン
* `permissions`は必要な権限

おそらくこんな感じだと思います。

ちなみにバージョンが1.0.0なのは、[セマンティック・バージョニング](https://semver.org/lang/ja/)を使おうと思っているからです。
思っているだけであって実際にどうすればいいのかはわかっていません。
今後更新するようなことがあってから考えます。<=たぶん痛い目見る

:::note alert
**2021/12/07 追記**
あのー...バグ修正したのでバージョンを1.0.1に引き上げたんですけど、これで大丈夫でしょうか...(不安)
:::


下の方はこんな感じです。

```json:manifest.json
{
  "content_scripts": [
    {
      "matches": [
        "https://www.nnn.ed.nico/*"
      ],
      "js": [
        "content-script.js"
      ]
    }
  ]
}
```

おそらく、`matches`にはスクリプトを動かしたいURLの正規表現を書いて、
`js`にはJavaScriptファイルへのパスを書く感じだと思います。
いやでもなんか違う気がする...

この辺はよくわかりません。


## content-script.js

コード本体はこちらです。

https://github.com/nanasi-1/N-Extension/blob/main/content-script.js

~~上の方に余計なものが混ざっていますが、これは無視して、~~

:::note info
**2023/12/7 追記**
余計なものは消しました。
もっと早く消すべきでした。すみませんでした。

<details><summary>余計なもの</summary>

見ての通り何の役にも立たないどころか余計な動作をするクソコードです。

```js:ただのクソコードにすらなれない何か
let count = 0;

document.body.addEventListener('keydown', (event) => {
    if (event.key === 'Escape') {
        count++;
        if (count === 2) {
            window.open('https://www.nnn.ed.nico/contents/guides/2175/');
            count = 0;
        }
        setTimeout(() => count = 0, 500);
    }
})
```
</details>

:::

まず下の方から解説します。

```js:content-script.js
// bodyの変化を検知
let oldUrl = '';
const observer = new MutationObserver(e => {
    if(oldUrl !== location.href) {
        window.dispatchEvent(new CustomEvent('urlChange'));
    }
    oldUrl = location.href;
});
observer.observe(document.body, {childList: true, subtree: true});

async function sleep(sec) { 
    return new Promise(resolve => setTimeout(resolve, sec)); 
}
```

上の方にあるのはURLの変更を検知するためのものです。
詳しくは下の記事に書いてあるので、興味があれば見てみてください。

https://qiita.com/nanasi-1/items/dbaee5d609c2199e861b

その下にあるのは、`sleep`関数です。
`await sleep(500)`という感じで使います。
上の場合なら500ミリ秒待ちます。

一番上の方の余計なコードは無視して、
その下にあるコードの解説をします。

```js:content-script.js
window.addEventListener('urlChange', async function() {
    if(!(new RegExp('https://www.nnn.ed.nico/courses/[0-9]+/chapters/[0-9]+/.*')).test(location.href)) return;

    // 処理
});
```

まずここは、下の方のコードで作った`urlChange`イベントが発火されたときに中の処理をするという部分です。
一番最初に、開いているURLが指定した正規表現にマッチしなかった場合は処理を中断するようにしています。
<sub>`new RegExp`が使われているのは、`/`を`\/`に置き換えるのが面倒だったからです。</sub>

```js:content-script.js
if(!/*略*/.test(location.href)) return;

await sleep(500);
document.querySelectorAll('ul[aria-label="課外教材リスト"]>li').forEach(li => {
    if(li.querySelector('i[type*=rounded]').getAttribute('type') !== 'exercise-rounded') return;

    li.onclick = () => handle(que);
});
```

この部分では、まず`await sleep(500)`で500ミリ秒待つことにより、
ページの読み込み前にコードが実行されるのを防いでいます。
<sub>これ、もっといい方法はありませんかね...
`load`イベントとかだと動かないんですよね...確か...</sub>

また、教材選択画面の時に表示される、項目を示す`li`タグでループしています。

ループ内では、そのliタグが問題を示すものではない場合は`return`しています。
そして、問題を示すものであった場合は`onclick`イベントを設定しています。

イベントが発生したら`handle`関数を`que`という引数で実行するようにしています。
この`handle`関数は今から解説します。

* * *

こちらが`handle`関数です。
メイン処理と言っても過言ではない関数となっています。

```js:content-script.js
/** @param {Document} que */
async function handle(que) {
    console.log('N予備校系: 問題がクリックされました');
    await sleep(1000);

    // 略

    // 間違っている問いがあるかどうかチェッカー
    que.querySelector('.evaluate-button').onclick = () => {
        console.log('回答しました');
        const answers = que.querySelectorAll('.answers > li[data-correct=true]');
        const arr = [];
        for (let answer of answers) {
            if (!answer.classList.contains('answers-selected')) {
                arr.push(answer.parentElement.previousSibling.innerText);
            }
        }
        alert(arr.length ? `間違っている問いがあります:\n${arr.join('\n')}` : '間違っている問いはありません');
    };
}
```

まず、この関数は`que`という引数を取ります。
この`que`はJSDocからも分かる通り`Document`オブジェクトです。
問題式教材の`document`を入れる想定となっています。

次に、解答ボタン(`.evaluate-button`)の`onclick`イベントを設定しています。

ボタンを押すと、まず`answers`という配列に正解の選択肢の`li`タグ一覧が入ります。
この配列でループして、`answer`という要素に**自分がその選択肢を選んでいることを示すクラス(`answers-selected`)がなかった場合**は、`arr`にその選択肢が追加されるようになっています。

前提として、**間違った回答をしているときとはつまり、正しい回答をしていないということになります**(小泉構文)。
上のコードではこれを利用し、間違った問題を探すのではなく、正しい選択肢が選ばれているかどうかを判定しています。

そして最後に`alert`でダイアログを出しています。


# 終わりに

今回初めてChromeの拡張機能を作ったのですが、
拡張機能ってすごいな...と思いました。
(自分の手元のみとはいえ)Webページを改変できるってすごい。

今後やる気が出れば、この拡張機能でテスト教材の正誤も判定できるようにしようと考えています。
<sub>やるかどうかはわかりませんが...</sub>

* * *

以上です。
ここまで読んでくださりありがとうございました。