LoginSignup
5
0

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

Last updated at Posted at 2023-11-18

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

すごく雑なきっかけ

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

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

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

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

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

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

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

作ったもの

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

スクリーンショット 2023-10-26 9.29.44.png

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

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

公開場所について

Chrome拡張機能といえばChromeウェブストアで公開しそうなものですが、
複雑な事情によりGitHubでソースコードを公開することにしました。

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

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

注意点

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

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

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

仕組み

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

ファイル構成

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

N-Extension
├── LICENSE
├── README.md
├── content-script.js
└── manifest.json

MacでTreeコマンドを使いディレクトリ構造をテキスト出力するを参考にして、
treeコマンドをインストールしてみました。

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

manifest.json

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

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

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

ざっくり言ってしまうと、

  • nameは拡張機能の名前
  • descriptionは拡張機能の説明
  • versionはバージョン
  • permissionsは必要な権限

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

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

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

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

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

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

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

content-script.js

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

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

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

余計なもの

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

ただのクソコードにすらなれない何か
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);
    }
})

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

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の変更を検知するためのものです。
詳しくは下の記事に書いてあるので、興味があれば見てみてください。

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

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

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が指定した正規表現にマッチしなかった場合は処理を中断するようにしています。
new RegExpが使われているのは、/\/に置き換えるのが面倒だったからです。

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ミリ秒待つことにより、
ページの読み込み前にコードが実行されるのを防いでいます。
これ、もっといい方法はありませんかね...
loadイベントとかだと動かないんですよね...確か...

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

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

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


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

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ページを改変できるってすごい。

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


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

5
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
0