N予備校の教材をブックマークする拡張機能を作った話

# N Bookmarksについて

N Bookmarksは、N予備校の教材をブックマークすることができる拡張機能です。

この拡張機能を入れると、「質問する」ボタンの隣に **「ブックマーク」ボタンが出現するようになります。**

![btn.png](https://camo.githubusercontent.com/7deb330d5764889d58fd73f48ab970b4bb82d74a049fc849d21a4bc3dd0fd0a2/68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f333538383038332f36383739346461312d366534302d646238632d363639652d3364343462396235343862352e706e67)

ブックマークした教材は **「マイコース」から見ることができます。**

![マイコース.png](https://camo.githubusercontent.com/eaf3cd661bc5f63fdf3ffd34fbd539d431b90219704a7d5b4a98b9042e92af92/68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f333538383038332f36616637323032612d306130392d353461392d373166372d6365353339363430613138342e706e67)

上のように、教材の上にブックマークボタンを追加し、ブックマークした教材一覧をマイコースから見るという、非常にシンプルな構成となっております。


## 公開場所

[前回](https://qiita.com/nanasi-1/items/bb9c14be56e8d01c26f0)に引き続き、この拡張機能もChromeウェブストアでは公開していません。
理由は相変わらず登録料を支払う手段がないからです。

そのため、この拡張機能の公開はGitHubのみとなります。
インストールは↓こちら

https://github.com/nanasi-1/n-bookmarks

ちなみに、前回というのは[N予備校の回答チェッカー](https://github.com/nanasi-1/N-Extension)を作ったことです。
これについては記事も書いているので、よければそちらも見てみてください。

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


## 対応状況

今のところテキストと問題式の教材に対応しています。

動画の上にもブックマークボタンは表示され、押したらブックマークすることもできますが、うまくスタイルが適用されないバグがあります。
<sub>めんどいので直していません(</sub>


## 作ったきっかけ

もともとフォーラムで

> 何度も見返したい授業にブックマークをつける機能が欲しい

という要望を見かけたのがきっかけです。

といってもこの投稿を見てすぐに作り始めたのではなく、2ヶ月ほど経ってからふとこの質問のことを思い出して作り始めました。

* * *

さて、ここで疑問に思いませんか？

フォーラムで見かけた質問には「**授業**」をブックマークする機能が欲しいと書いてあったのに、なぜ作った拡張機能は「**教材**」をブックマークするのか、と。

この答えは簡単です。

フォーラムでこの投稿を見かけてから2ヶ月が経っているせいで**記憶があやふや**になっており、そして自分自身が欲しいと思ったのが教材をブックマークする機能だったせいで**記憶が都合よく改竄**されていました。
その結果出来上がったのが、N Bookmarksこと教材をブックマークする拡張機能です。

<sub>...まあもともと自己満だし、誰かに使ってもらうために作ったわけじゃないし...
別に質問内容勘違いして作っちゃったのを気にしてるわけじゃないし...</sub>


# コード紹介

ということでコード紹介していきます。

まず、この拡張機能は以下の4つのファイルからできています。

```
n-bookmarks
├── README.md
├── add.js
├── get.js
└── manifest.json
```

`manifest.json`はChrome拡張機能の設定ファイルです。
ここに実行するjsファイルや拡張機能のバージョンを書きます。
詳しくは[manifest.json](#manifestjson)の節を見てください。

`get.js`はブックマーク一覧をマイコースに表示させるコードです。
また、関数の定義もこちらで行なっています。
詳しくは[get.js](#getjs)の節を見てください。

`add.js`は教材をブックマークするボタンを追加するコードです。
ここでは`get.js`で定義された関数も使っています。
詳しくは[add.js](#addjs)の節を見てください。

`README.md`は言わずもがななので解説は省きます。

ここからは`README.md`以外の各ファイルについて解説していきます。


## manifest.json

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

https://github.com/nanasi-1/n-bookmarks/blob/main/manifest.json

`manifest.json`は、前述した通りChrome拡張機能の設定ファイルです。
このファイルに権限設定やバージョンなどを書きます。

```json:manifest.json
{
    "manifest_version": 3,
    "name": "N Bookmarks",
    "description": "N予備校の教材をブックマークしたい",
    "version": "1.0.2",
    "permissions": [
        "storage"
    ],
    "content_scripts": [
        {"matches": ["https://www.nnn.ed.nico/*"], "js": ["get.js", "add.js"]}
    ]
}
```

* `manifest_version`は、このマニフェストがどのバージョンで書かれているかを示しています。
ここでは`3`に設定しています。
* `name`は、この拡張機能の名前を示しています。
* `description`は、この拡張機能の説明を示しています。
* `version`は、この拡張機能のバージョンを示しています。


### permissions

`permissions`は、この拡張機能を実行するにあたって必要な権限を指定します。

```json:manifest.jsonの一部
"permissions": [
    "storage"
]
```

N Bookmarksではブックマーク情報の保存先として`chrome.storage.local`を使用したいので、`permissions`に`storage`を入れています。

ここで指定できる権限は、[こちら](https://developer.chrome.com/docs/extensions/develop/concepts/declare-permissions?hl=ja)を参考にしてください。


### content_scripts

`content_scripts`には、特定のURLにマッチしたときに実行するJSファイルを指定します。

```json:manifest.jsonの一部
"content_scripts": [
    {
        "matches": ["https://www.nnn.ed.nico/*"], 
        "js": ["get.js", "add.js"]
    }
]
```

この場合だと、`"https://www.nnn.ed.nico/*"`というURLにマッチしたときに、
`get.js`と`add.js`という2つのファイルを実行するという意味になります。

ですが、`get.js`は`https://www.nnn.ed.nico/my_course`にアクセスしたときにだけ動けばいいプログラムのはずです。
じゃあなぜ上のようにしているのか？というと、しっかり理由があります。

まず、N予備校は(たぶん)[SPA](https://www.profuture.co.jp/mk/column/28468)です。
そのせいで**ページ内遷移をした際に`window.onload`などのイベントが起動しません。**

N Bookmarksでは、マイコースページを開いたときにブックマーク一覧を表示するプログラム(`get.js`)を実行したいです。
また、教材ページが開いたらブックマークボタンを追加するプログラム(`add.js`)を実行したいです。

なので最初は以下のようにしようと思っていました。

```json:manifest.json
"content_scripts": [
    {
        "matches": ["https://www.nnn.ed.nico/my_course"], 
        "js": ["get.js"]
    },
    {
        "matches": ["https://www.nnn.ed.nico/courses/*/chapters/*"], 
        "js": ["add.js"]
    }
]
```

ですが、これだとページ内遷移したときに実行されませんでした。

こういう時のためにバックグラウンドがあるのかなぁとも思いましたが、<sub>面倒だったため</sub>コンテンツスクリプトのみで乗り切ることにしました。

その結果、`manifest.json`は上のようになり、`get.js`と`add.js`は

1. URLが変わった際に`urlChange`イベントを発生させる
1. `urlChange`イベントがあったら、URL(`location.href`)がプログラムを実行したいURLのパターンにマッチするか調べる
1. マッチする場合は、その下に書いたプログラムを実行

のようになりました。


## get.js

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

https://github.com/nanasi-1/n-bookmarks/blob/main/get.js

前述の通り、`get.js`はマイコースにブックマーク一覧を追加するプログラムです。

そして、上に書いたように、このファイルは以下のようになっています。
> 1. URLが変わった際に`urlChange`イベントを発生させる
> 1. `urlChange`イベントがあったら、URL(`location.href`)がプログラムを実行したいURLのパターンにマッチするか調べる
> 1. マッチする場合は、その下に書いたプログラムを実行

この「その下に書いたプログラム」とは、マイコースページのDOMを変更して、ブックマーク一覧を画面に表示するコードです。

また、このファイルには関数も定義されています。
ついでにVSCodeで補完を利用するためのJSDocコメントも書かれています。

### 関数群

`get.js`では以下のコードが書かれています。

* 関数群の定義
* `urlChange`イベントの発行
* 補完のためだけに書かれたJSDoc

まず、`get.js`と`add.js`は同じスレッド(?)で実行されます。
そのため、片方のファイルに書いた関数は、インポートやエクスポートなしで、もう片方のファイルでも使えます。
そのため`get.js`に書いた関数は`add.js`でも使うことができます。

:::note info
ちなみに、関数群を`get.js`に書いた理由は特にありません。
強いていうなら`get.js`のほうが行数が少なかったからです。
:::


関数群はこちらです。

```js:get.jsに書かれた関数群
/** ブックマークを取得する関数 @returns {Promise<Map<string, Bookmark>>} */
async function getBookmarks() {
    try {
        /** @type {{bookmarks: Bookmark[]}} */
        const bookmarks = await chrome.storage.local.get('bookmarks');
        if (!bookmarks.bookmarks) {
            return null;
        } else {
            return new Map(JSON.parse(bookmarks.bookmarks))
        }
    } catch (e) {
        alert('エラーが発生しました。再読み込みしてください。');
        throw new Error('エラーが発生しました:', e);
    }
}

/** 文字列をHTMLElementにする関数 @param {String} str @returns {HTMLElement} */
function strToElement(str, inHTML = false) {
    const tempEl = document.createElement(inHTML ? 'html' : 'body');
    tempEl.innerHTML = str;
    return inHTML ? tempEl : tempEl.firstElementChild;
}

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

`sleep`は一定時間待つ関数です。
例えば`await sleep(500)`としたら500ミリ秒待ってくれます。
実行するのが早すぎた処理を遅らせるのに使っています。

`strToElement`は文字列をHTMLElementに変換する関数です。
あると色々便利なため入れています。
以下の記事を参考にして作りました。

https://qiita.com/seijikohara/items/911f886d8eb79862870b

`getBookmarks`は、ブックマーク一覧を取得する関数です。
この関数ではブックマーク一覧をMap形式で返しています。

また、コンテキストがどうのこうのというエラーが出ることがあったため、`try-catch`で囲っています。


### データの構造について

まず、ブックマークした教材のデータは、`chrome.storage.local`という場所で管理しています。
これはChrome拡張機能で使えるストレージです。多分。
`manifest.json`の権限設定で`storage`を指定すると使えるようになります。

このストレージは、データをキーと値のペアで管理します。
この点ではローカルストレージと似ていますが、使い方は結構違います。

データのセットには`set()`メソッドを使います。
引数に`{key1: value1, key2: value2}`という形式の値を入れて使います。

```js:データをセットする例
// ストレージに、キーが aaa で値が 111 のデータをセット
await chrome.storage.local.set({aaa: 111});

// ストレージに、キーが bbb で値が222、キーが ccc で値が 333 のデータをセット
await chrome.storage.local.set({bbb: 222, ccc: 333});
```

データの取得には`get()`メソッドを使います。
プロパティ名を引数に指定することで値を取得できます。

```js:データの取得
// bookmarksというプロパティのデータを取得
await chrome.storage.local.get('bookmarks'); // {bookmarks: 'str'}
```

これ以外にも`delete`や`clear`といったメソッドがありますが、説明は省きます。
詳しくはドキュメントをご覧ください。

https://developer.chrome.com/docs/extensions/reference/api/storage?hl=ja

***

さて、ではN Bookmarksのデータ構造について軽く紹介します。

まず、ブックマークのデータはすべて`bookmarks`プロパティにJSON形式の文字列で格納されています。
その文字列の元になるデータは配列になっており、`[mapにするときのキー=教材URL, 値]`という風になっています。

この値は`Bookmark`というデータ型で、`get.js`の一番最初にJSDocコメントで書かれています。
以下のような型です。
```ts:Bookmark型のJSDoc
/**
 * @typedef Bookmark
 * @prop {string} url
 * @prop {string} title
 * @prop {string} chapterName
 * @prop {string} courseName
 * @prop {string} chapterUrl
 * @prop {string} courseUrl
 */

// TypeScript風にいうとこんな感じ
interface Bookmark {
  url: string;
  title: string;
  chapterName: string;
  courseName: string;
  chapterUrl: string;
  courseUrl: string;
}
```

以下に例を示します。
```json:こういう感じ
{
    "bookmarks": [
        [ "Mapのキー(教材のパス)", {
            "url":"教材URL再び",
            "title":"教材タイトル",
            "chapterName":"チャプター名",
            "courseName":"コース名",
            "courseUrl":"コースのURL",
            "chapterUrl":"チャプターのURL"
        } ],
        ["上のようなやつが続く", { ちなみにここはBookmarks型 }]
    ]
}
```

上の例はイメージのために作られたものなので文字列になっていませんが、実際に`Bookmarks`プロパティに入っているのは文字列です。

***

ただ、配列のままだと何かと扱いづらいです。
なので実際にブックマークのデータを取得するときはMapにしたいです。

現在`Bookmarks`プロパティの中に入っているJSONは、`[Mapのキー,Bookmark][]`という形になっています。
なので、`new Map()`の中にそのまま入れるだけで
`Map<教材のパス(絶対被らない), Bookmark>`といういい感じのやつが出来上がります。

そんな感じの処理をしたくて出来上がったのが`getBookmarks`関数です。

```js:get.jsのgetBookmarks関数
/** ブックマークを取得する関数 @returns {Promise<Map<string, Bookmark>>} */
async function getBookmarks() {
    try {
        /** @type {{bookmarks: Bookmark[]}} */
        const bookmarks = await chrome.storage.local.get('bookmarks');
        if (!bookmarks.bookmarks) {
            return null;
        } else {
            // Mapにして返す
            return new Map(JSON.parse(bookmarks.bookmarks));
        }
    } catch (e) { // たまに出るエラーの対策
        alert('エラーが発生しました。再読み込みしてください。');
        throw new Error('エラーが発生しました:', e);
    }
}
```


### メイン処理

ということで、次はメイン処理を見ていきます。
といっても全部乗っけると長いので、要点部分の解説のみとなります。

以下のコードで`urlChange`イベントを受け取り、URLがマイコースのものでなければ`return`しています。
```js:get.js
window.addEventListener('urlChange', async () => {
    if (location.href !== 'https://www.nnn.ed.nico/my_course') return;

    // 処理
});
```

URLがマイコースのものだった場合は、ブックマークになるエリアと、スタイルを適用するためのクラスを取得します。

```js:get.js
// ブックマークになるエリア
const bookmarkArea = document.querySelector('[role="main"] > div > div:nth-child(2)');

// クラスを取得
const container = document.querySelector('nav[aria-label="コース一覧"]>div');
const ulClass = [...container.classList];
ulClass.push(container.querySelector('ul:has(li>a)').classList[1]);
const listTitleClass = [...container.querySelector('h3').classList]
    .concat([...container.querySelector('h3').parentElement.classList]);
const itemTitleClass = [container.querySelector('h4').classList[1]];
const aClass = [...container.querySelector('a').classList]
```

ちなみに、このコードだとクラスは配列で取得されます。
そのため、実際に適用するときは`class.join(' ')`という風にして埋め込みます。

クラスとブックマークエリアを取得したら、`strToElement`関数で`HTMLElement`オブジェクトを作成します。

```js:get.js
const ul = strToElement(`
    <ul class="${ulClass.join(' ')}">
        <li><h3 class="${listTitleClass.join(' ')}">ブックマーク</h3></li>
    </ul>
`);

const bookmarks = await getBookmarks();
for (const [path, bookmark] of bookmarks) {
    ul.append(strToElement(
        `<li>
            <a href="${bookmark.url}" class="${aClass.join(' ')}">
                <h4 class="${itemTitleClass.join(' ')}">${bookmark.title}</h4>
                <div>
                    ${bookmark.courseName} - ${bookmark.chapterName}
                </div>
            </a>
        </li>`
    ));
}

// ブックマークがない場合
if (bookmarks.size === 0) {
    ul.append(strToElement(`
        <li class="${aClass.join(' ')}">まだブックマークはありません</li>
    `));
}
```

このコードでは、`strToElement`関数を使うことにより、文字列から`HTMLElement`を作成しています。
基本的には見ての通りで、要所要所に変数をテンプレートリテラルで入れています。
<sub>ちなみに私はこれを書いているとき「Reactってこんな感じなんだろうなぁ」と思いました。
実際にこんな感じなのかは知りません。</sub>

そして最後に、作成した`ul`要素を`bookmarkArea`に入れています。
```get.js
// 表示エリアに表示する
bookmarkArea.innerHTML = '';
bookmarkArea.append(ul);
```


## add.js

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

https://github.com/nanasi-1/n-bookmarks/blob/main/add.js

このファイルでは、教材の右上にブックマークボタンを追加し、そのボタンを押すとブックマークが追加されるようにします。
また、ブックマークしている状態でボタンを押すとブックマークが解除されるようにします。


### appendBookmarkBtn

とりあえず、一つのコードが長くなりすぎないようにするため、ブックマークボタンを追加する関数`appendBookmarkBtn`を書きます。
...と思ったのですが、今見たらこの関数があまりにも長かったため、分割してお届けします。

まずは`strToElement`関数を使って`button`要素を作ります。
ついでにスタイルも適用します。
```add.js
async function appendBookmarkBtn(doc) {
    const btn = strToElement('<button id="bookmark-btn" class="u-button type-primary"></button>');

    // スタイルを適用
    btn.style.position = 'absolute';
    btn.style.right = '130px';
    btn.style.top = '0';
    btn.style.marginTop = '-10px';
    btn.style.padding = '0';
    btn.style.lineHeight = '42px';
    btn.style.width = '130px';

    // 中の文字を変更
    btn.textContent = (await getBookmarks())?.has(location.pathname) ? 'ブックマーク中' : 'ブックマーク';
```

最後の行では、現在のURLのパス部分(`location.pathname`)がブックマークデータに含まれるかどうかで、ボタンの中の文字を変えています。

:::note warn
何も考えずにコードを書いたせいで、現在のパスがブックマークされているかという変数が作られていません。

後ほどもう一度ブックマークされているかが必要になる箇所がありますが、
そちらはそちらで判定をし直していて非効率なコードとなっています。

なのでこの部分は真似しないことをお勧めします。
:::

クリックしたらブックマークする処理、ブックマークを外す処理はこんな感じです。

```add.js
btn.addEventListener('click', async e => {
    // 現在のブックマークのデータ
    const bookmarkData = await getBookmarks() || new Map();

    if (bookmarkData.has(location.pathname)) {
        // ブックマーク済みの場合はブックマークを解除
        
        // ストレージから削除
        chrome.storage.local.set({ bookmarks: JSON.stringify([...bookmarkData.entries()]) });

        // 中の文字を編集
        e.target.textContent = 'ブックマーク';
        
    } else {
        // ブックマークしていない場合はブックマーク

        // 教材のタイプ、型は('exercise' | 'movie' | 'guide')
        const contentType = location.href.match(/exercise|movie|guide/)[0];

        // リンクや名前のデータを取るための要素
        const courseElem = document.querySelector('[aria-label="パンくずリスト"] li:nth-child(3)>a');
        const chapterElem = document.querySelector('[aria-label="パンくずリスト"] li:nth-child(5)>a');
        
        const title = // 教材タイトル、長いため略

        /** 新しいブックマークのオブジェクト @type {Bookmark} */
        const newBookmark = {
            url: location.href,
            title,
            chapterName: chapterElem.textContent,
            courseName: courseElem.textContent,
            courseUrl: courseElem.href,
            chapterUrl: chapterElem.href,
        };

        // ストレージを更新
        chrome.storage.local.set({ 
            bookmarks: JSON.stringify([...bookmarkData.entries()]) 
        });

        // ボタンの中の文字を更新
        e.target.textContent = 'ブックマーク中';
    }
});
```

結構長くなってしまった...

上のコードはやっていることは単純で、

1. ブックマークのデータを取得
1. 現在のパスがブックマーク済みか確認
1. ブックマーク済みの場合：
    1. ストレージからブックマークを削除
    1. ボタンの中の文字を更新
1. ブックマークしていない場合：
    1. 新しいブックマークのオブジェクトを作成
    1. 作成したオブジェクトを元にストレージを更新
    1. ボタンの中の文字を更新

これだけです。

そして最後に、ボタンを教材のヘッダー部分に追加しています。

```get.js
const header = doc.querySelector('header');
header.insertBefore(btn, header.querySelector('#question-btn'));
```


### メイン処理

次はメイン処理です。
この処理は大きく3ステップに分かれています。

1. `urlChange`イベントを受け取り、URLがパターンにマッチするか調べる
1. 教材URLの場合はボタンを追加
1. 教材が選択されたらボタンが追加されるようにする

まずは`urlChange`イベントを受け取り、URLが教材のパターンにマッチしない場合は`return`します。
ここでいう「教材のパターン」では、

* チャプターが選択されている状態のURL
* 教材が選択されている状態のURL

のどちらかの場合はマッチするようになっています。

```add.js
window.addEventListener('urlChange', async e => {
    if(!(new RegExp('https://www.nnn.ed.nico/courses/.*/chapters/.*')).test(location.href)) return;

    // 処理
});
```

次に、現在開いているページが教材URLの場合は、ブックマークボタンを追加します。

```add.js
if ((new RegExp('/courses/.*/chapters/.*/.*/.*')).test(location.href)) {
    appendBtn(100, 20);
}
```

前述した通り、上のステップでマッチするのは

* チャプターが選択されている状態のURL
* 教材が選択されている状態のURL

です。

そして、現在開かれているのが「教材が選択されている状態のURL」だった場合は、即座にブックマークボタンを追加したいです。

なので、上のようなコードで教材URLかどうか判定し、`appendBtn`関数でブックマークボタンを追加しています。

:::note info
上のコードに出てくる`appendBtn`関数は、`appendBookmarkBtn`関数と同じだと思ってください。
`appendBookmarkBtn`を直接使うとページの読み込み速度の問題でエラーが出てしまったため、`appendBtn`関数(別で実装)が使われています。
:::

最後に、教材を選択するliタグにクリックイベントを追加します。
このイベントにより、liタグがクリックされたら開いた教材にボタンが追加されます。
<sub>と思ったけどなんか要らないような気がしてきた...</sub>

```add.js
// 教材のliタグにイベントを追加
document.querySelectorAll('ul[aria-label="課外教材リスト"] > li').forEach(li => {
    li.addEventListener('click', async () => {
        appendBtn(100, 20);
    });
});
```


# 終わりに

今回のN Bookmarks制作を通して思ったのは、こういう拡張機能ってしょせん後付けに過ぎないんだなぁってことです。

もしも仮にN予備校の運営がブックマーク機能を追加しようと思ったら、きっとブックマークの内容N予備校アカウントで同期されると思います。

ですが今回の拡張機能だと、データは(おそらく)chromeに保存されます。
N予備校アカウントでデータを同期することは拡張機能だけだと無理ですし、そもそもchrome以外のブラウザだと動きません。

そんな感じなので、拡張機能はあくまで後付けだなと思ってしましました。

***

この記事を通してN Bookmarksに興味が出た方、ぜひ一度試してみてください。
フィードバックとかもらえると喜びます。

https://github.com/nanasi-1/n-bookmarks

また、N予備校のアドベントカレンダーも参加お待ちしてます(なぜお前が待ってるんだ


https://qiita.com/advent-calendar/2023/nyobi

ついでにN予備校もよろしくお願いします(だからなぜお前がry

https://www.nnn.ed.nico/pages/programming/