小説家になろうの読書体験をちょっとだけ良くするchrome拡張を書いた

## はじめに

この記事は「[PERSOL PROCESS & TECHNOLOGY Advent Calendar 2020](https://qiita.com/advent-calendar/2020/ppt)」の20日目の記事になります。
私は四回目の登場です。今年のアドカレMVPは頂くぜ！とか叫んでいたのですが、思いっきり遅刻しています(21日目の担当も私だったのですが、今日は22日です。どうしてこんなことに)。
素敵な先輩方と同期が素敵な記事をたくさん書かれているので、是非他もご覧になってみてください。

## 概要

皆さんネット小説は好きですか？私は大好きです。紙のラノベを合わせると月に数百万文字は読んでいると思います。このアドカレで遅刻を繰り返しているのもひとえにネット小説(特に私の場合は小説家になろう)の作品にハマりまくっているからです。
力作を投稿していただいている作者様方、そしてプラットフォームを整えていただいている運営様には本当に毎日感謝の日々です。

ただ、特にPCからブラウザでなろうを使えばわかると思うのですが、「もうちょっとこうなれば良いのになあ」という箇所がサイトに存在するのもまた事実です。
ということで、それらをユーザーの側から少し補助できるようなChrome拡張を書きました。

- 「後で読む」ボタンの実装
- しおりの自動更新機能を実装

以上の二つを実装したので解説します。

## 「後で読む」ボタン

### 機能の概要

「この小説気になるけど今読めるタイミングじゃないし、とりあえずタブだけ開いとくか......」みたいなことありませんか？私はあります。常時50個ほどタブが開かれています。

ブックマークに何でもかんでも放り込むという手もありますが「ブックマークは自分が実際に読んで気に入った作品を登録するものでは？」という思いが浮かび、カテゴリ分けである程度解決できるとはいえ一々やるのが億劫な身としては気が向かず、結局今日も今日とてタブを乱立させています。
微妙に不便なので「後で読む」ボタンを作って、押したらリストに登録されるようにしました。

![header.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/246248/c20c58df-0c90-9541-75d9-6c28b9c5b26c.png)

![list.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/246248/46d9edfd-831b-1e1d-acd3-5c9eb614c08c.png)


こんな感じです。

日付を見れば分かる通りちょっと前に便利だと思って作りましたが、今日記事を書くにあたって思い返したところ、意外に自分は使っていないことに気づきました(使っていれば絶対に気づいたであろう致命的なバグに気づいていませんでした)。

chrome拡張機能がスマホで使えないのはネックになりますね......。
PCで作品を漁りまくっているという方には使っていただけるかもしれません。

### 実装

実装は少し泥臭く生のJSでやっていましたが、登録完了を通知するmodalを実装しようとして(昨日です)結局jQueryに手を出しました。
content_scripts.jsでやっていることは単純で、画面ロード時にボタンを追加してやって、それがクリックされるとブラウザのlocal storageにURLなりタイトルなり登録日時なりが格納されるイベントを定義しているだけです。
local storageに保存するため、複数のPCでデータが同期されることはありません。

<details>
<summary>content_scripts.js(折り畳み)</summary><div>
```js:content_scripts.js
// 後で読む機能
function setReadLaterButton() {
    const urlElements = location.href.split('/');
    if (urlElements.length < 5) {
        return;
    }
    var novelId = '';
    novelId = urlElements[3];
    const url = `https://ncode.syosetu.com/${novelId}/`;
    var contents1 = document.getElementsByClassName('contents1')[0];
    var headNav = document.getElementById('head_nav');

    var title = '';
    if (urlElements.length === 5) {
        title = document.getElementsByClassName('novel_title')[0].textContent;
    }
    else if (urlElements.length === 6) {
        title = contents1.getElementsByTagName('a')[0].textContent;
    }

    var laterButton = document.createElement('a');
    laterButton.id = 'later-button';
    laterButton.href = '#';
    laterButton.innerHTML = '「後で読む」に追加';

    // クリック時イベント定義
    laterButton.addEventListener('click', function() {
        var entity = {};
        const date = new Date();
        entity['$'+novelId] = {
            "title": title,
            "url": url,
            "number": 0,
            "date": date.getTime()
        }
        console.log(entity);
        // local storageに保存
        chrome.storage.local.set(entity, function () {
            console.log('readLaterList updated!');
            chrome.runtime.sendMessage({ message: "readLater"}, function (res) {
            });
            setModal();
        });
    });
    headNav.appendChild(laterButton);
}

// 登録完了のmodalを出す
function setModal() {
    var body = document.querySelector('body');
    var modal = document.createElement('div');
    const modalHtml =  `<div class="modal js-modal">
                            <div class="modal__bg js-modal-close"></div>
                            <div class="modal__content">
                                <p>登録が完了しました</p>
                                <a class="js-modal-close" href="#">閉じる</a>
                            </div><!--modal__inner-->
                        </div><!--modal-->`;

    modal.innerHTML = modalHtml;
    body.appendChild(modal);

    $(function(){
        $('.js-modal').fadeIn();
        $('.js-modal-close').on('click', function() {
            $('.js-modal').fadeOut();
        });
    });
}

// stock.jsに更新を通知する
chrome.runtime.onMessage.addListener(function (req, sender, sendResponse) {
    if (req.message === 'novelId') {
        const url = location.href;
        var urlElements = url.split('/');
        var response = '';
        if (urlElements.length >= 5) {
            response = urlElements[3];
        }
        sendResponse(response);
    }
    return true
});
```

</div></details>

stock.htmlとstock.jsは拡張機能のアイコンをクリックした時に出てくるポップアップを描画するためのファイルです。
local storageに保存されているデータを引っ張ってきて、良い感じにレイアウトを整えたり削除ボタンを付けたりしています。

<details>
    <summary>stock.jsとstock.html(折り畳み)</summary><div>

```js:stock.js
function getReadLaterData(listContainer) {
    return new Promise((resolve, reject) => {
        chrome.storage.local.get(function (items) {
            console.log(items);
            var list = [];
            for (const [key, value] of Object.entries(items)) {
                var ob = {
                    "id": key,
                    "title": value['title'],
                    "number": value['number'],
                    "url": value['url'],
                    "date": value['date']
                }
                list.push(ob);
            }

            list.sort(function (a, b) {
                if (+a.date > +b.date) {
                    return -1;
                }
                else {
                    return 1;
                }
            });

            list = list.filter((el) => el.id[0]==='$');
            console.log(list);
            const param = [listContainer, list]
            resolve(param);
        });
    });
}

function showReadLaterList(param) {
    return new Promise((resolve, reject) => {
        var listContainer = param[0]; var readLaterList = param[1];
        console.log(readLaterList);
        readLaterList.forEach(el => {
            const novelTitle = el['title'];
            const url = el['url'];

            var readLater = document.createElement('div');
            readLater.classList.add('list-element');

            var removeButton = document.createElement('button');
            removeButton.type = 'button';
            removeButton.setAttribute('name', el['id']);
            removeButton.classList.add('delete-button');
            removeButton.innerHTML = 'x'


            var titleLink = document.createElement('a');
            titleLink.classList.add('novel-title');
            titleLink.href = url;
            titleLink.setAttribute('target', '_blank');
            titleLink.setAttribute('rel', 'noopener noreferrer')
            titleLink.innerHTML = novelTitle;

            var dateParagraph = document.createElement('p');
            const date = new Date(el['date']);
            const dateString = `登録日: ${date.getFullYear()}年${date.getMonth()+1}月${date.getDate()}日`;
            dateParagraph.innerHTML = dateString;

            readLater.insertAdjacentElement('beforeend', titleLink);
            readLater.insertAdjacentElement('beforeend', dateParagraph);
            readLater.insertAdjacentElement('beforeend', removeButton);
            listContainer.appendChild(readLater);
        });

        resolve('showReadLaterList => setEvent');
    });
}

function updateReadLaterList() {
    var listContainer = document.getElementById('list-container');
    listContainer.innerHTML = null;
    getReadLaterData(listContainer)
        .then(showReadLaterList)
        .then(setEvent)
        .then((response) => {
            console.log(response);
            console.log('end');
        });
}

function setEvent(passVal) {
    return new Promise((resolve, reject) => {
        var deleteBtns = document.querySelectorAll('.delete-button');
        deleteBtns.forEach(el => {
            const novelId = el.getAttribute('name');
            el.addEventListener('click', function () { removeReadLater(novelId); }, false);
        });
        console.log(passVal);
        resolve('delete-buttons.length = ' + deleteBtns.length);
    })
}

function removeReadLater(novelId) {
    console.log(novelId);
    chrome.storage.local.remove(novelId, function () {
        console.log('removed');
        load();
    });
}

window.onload = load;

function load() {
    updateReadLaterList();
}

chrome.runtime.onMessage.addListener(function (req, sender, sendResponse) {
    if (req.message === 'readLater') {
        sendResponse('readLater updated!');
    }
    return true;
});

```

```html:stock.html

    <!DOCTYPE html>
    <html>

    <head>
        <meta charset="UTF-8">
        <link rel="stylesheet" href="/css/stock.css">
        <script src="/js/stock.js"></script>
    </head>

    <body>
        <h2>後で読むリスト</h2>
        <a href="setting.html">設定</a>
        <h3>後で読むに追加した小説一覧</h3>
        <div id="list-container">
        </div>
    </body>

    </html>

```
</div></details>

### しおりの自動更新機能

小説家になろうのしおり機能は、しおりというわりに何故か自動で更新されません(これ実は設定する方法とかありますか？)。既に200部分まで読み終えているのにしおりは3部分に付いている(3部分の時にブックマーク登録した)などざらだと思います。
大抵タブを開きっぱなしにしていればしおりが無くても困ることはありませんが、端末をまたいだ時やタブを消してしまったときには大きな助けになります。

これはなろうの仕様ですが、ページのヘッダーにある\<input name="siori_url"\>をクリックしてやればしおりの位置情報を更新する処理を走らせることができます。
そのため、やるだけなら `document.getElementsByName("siori_url")[0].click()` というようなコードを一行書いておけばそれで事足ります。

しかししおりの自動更新をしたくないという状況もあり得るような気がしたので、やはりlocal storageにON/OFFの情報を持たせて「もしONであれば自動更新する、OFFになっていれば自動更新しない」というロジックを組みました。
サイトのHTML要素に直接手を入れるので、content_scripts.jsに以下を書き足しています。

```js:content_scripts.js

// しおり自動更新
function updateSiori() {
    const sioriButton = document.getElementsByName("siori_url")[0];
    if (sioriButton) {
        chrome.storage.local.get('setting', function (item) {
            console.log(item['setting']);
            if (!Object.keys(item).length || item['setting'] === 'ON') {
                sioriButton.click();
            }
        });
    }
}

```

そしてsetting.html, setting.cssでON/OFFボタンを作り、ONが押されればONが、OFFが押されればOFFが、local storageに保存されるようにしました。
なおON/OFFボタンの作り方は[こちら](https://webparts.cman.jp/button/onoff/)を参考にしました。

<details>
<summary>setting.js</summary><div>

```html:setting.html

<!DOCTYPE html>
<html>

<head>
    <meta charset="UTF-8">
    <link rel="stylesheet" href="/css/setting.css">
    <script src="/js/setting.js"></script>
</head>

<body>
    <h2>栞の自動更新設定</h2>
    <a href="stock.html">後で読むリスト</a>
    <div class="sioriONOFF" id="makeImg">
        <input type="radio" name="sioriRadio" id="sioriON">
        <label for="sioriON">ON</label>
        <input type="radio" name="sioriRadio" id="sioriOFF">
        <label for="sioriOFF">OFF</label>
    </div>
</body>

</html>

```
</div></details>

<details>
    <summary>setting.css</summary><div>

```css:setting.css

body {
    min-width: 200px;
    max-width: 200px;
    height: 200px;
    background-color: #fff;
}

h2 {
    color: #494949;/*文字色*/
    padding: 0.4em 0.5em;/*文字の上下 左右の余白*/
    background: #f4f4f4;/*背景色*/
    border-left: solid 5px #2756d8;/*左線*/
    border-bottom: solid 3px #d7d7d7;/*下線*/
}

/* === ボタンを表示するエリア ============================== */
.sioriONOFF {
    position       : relative;            /* 親要素が基点       */
    margin         : auto;                /* 中央寄せ           */
    width          : 160px;               /* ボタンの横幅       */
    height         : 60px;                /* ボタンの高さ       */
}

/* === ラジオボタン ======================================== */
.sioriONOFF input[type="radio"] {
    display        : none;            /* チェックボックス非表示 */
}

/* === ラジオボタンのラベル（標準） ======================== */
.sioriONOFF label {
    display        : block;               /* ボックス要素に変更 */
    position       : absolute;            /* 親要素からの相対位置*/
    top            : 0;                   /* 標準表示位置(上)   */
    bottom         : 0;                   /* 標準表示位置(下)   */
    left           : 0;                   /* 標準表示位置(左)   */
    right          : 0;                   /* 標準表示位置(右)   */
    text-align     : center;              /* 文字位置は中央     */
    line-height    : 60px;                /* 1行の高さ(中央寄せ)*/
    font-size      : 18pt;                /* 文字サイズ         */
    font-weight    : bold;                /* 太字               */
    border         : 2px solid #ccc;      /* 枠線(一旦四方向)   */
}

/* === ON側のラジオボタンのラベル（標準） ================== */
.sioriONOFF #sioriON + label {
    right          : 50%;                 /* 右端を中央に変更   */
    border-radius  : 6px 0 0 6px;         /* 角丸(左側の上下)   */
    background     : #eee;                /* 背景               */
    color          : #666;                /* 文字色             */
    border-right   : none;                /* 枠線の右側を消す   */
}

/* === ON側のラジオボタンのラベル（ONのとき） ============== */
.sioriONOFF #sioriON:checked +label {
                                        /* 背景グラデーション */
    background     : linear-gradient(180deg, #00b359, #006633, #00b359);
    color          : #fff;                /* 文字色             */
    text-shadow    : 1px 1px 1px #333;    /* 文字に影を付ける   */
}

/* === OFF側のラジオボタンのラベル（標準） ================ */
.sioriONOFF #sioriOFF + label {
    left           : 50%;                 /* 左端を中央に変更   */
    border-radius  : 0 6px 6px 0;         /* 角丸(右側の上下)   */
    background     : #eee;                /* 背景               */
    color          : #666;                /* 文字色             */
    border-left    : none;                /* 枠線の左側を消す   */
}

/* === OFF側のラジオボタンのラベル（OFFのとき） ============= */
.sioriONOFF #sioriOFF:checked +label {
                                        /* 背景グラデーション */
    background     : linear-gradient(175deg, #ccc, #999, #ccc);
    color          : #fff;                /* 文字色             */
    text-shadow    : 1px 1px 1px #333;    /* 文字に影を付ける   */
}

```

</div></details>

<details>
    <summary>setting.js</summary><div>

```js:setting.js

window.onload = load;

function load() {
    setEvent();

    chrome.storage.local.get('setting', function (item) {
        console.log(item);
        if (Object.keys(item).length) {
            const setting = item['setting'];
            if (setting === 'ON') {
                sioriON();
            }
            else if (setting === 'OFF') {
                sioriOFF();
            }
        }
        else {
            sioriON();
            updateSetting();
        }
    });
}

function sioriON() {
    var sioriONRadio = document.getElementById('sioriON');
    sioriONRadio.setAttribute("checked", '');
}

function sioriOFF() {
    var sioriOFFRadio = document.getElementById('sioriOFF');
    sioriOFFRadio.setAttribute("checked", '');
}

function updateSetting(param) {
    chrome.storage.local.set({'setting': param}, function() {
        console.log(`set ${param}`);
    });
}

function setEvent() {
    var sioriONRadio = document.getElementById('sioriON');
    var sioriOFFRadio = document.getElementById('sioriOFF');

    sioriONRadio.addEventListener('click', function () { updateSetting('ON'); }, false);
    sioriOFFRadio.addEventListener('click', function () { updateSetting('OFF'); }, false);
}

```
</div></details>
## おわりに

[GitHub](https://github.com/sarashinanikki/webnovel_bookmark_assist)に全部のコードを置いています。
もう少しUXの向上に力を入れていただければ良いのになあと思うものの、既に小説家になろうは最大手である以上、UIの改善をすることにあまりうまみがないのも理解できます。現状でも読むことに支障はまったくありませんし。
不満なら自分でやろうDIYということで、今回はChrome拡張で何とかしてみました。

とはいえユーザー側では容易に手を出せない範囲、例えば作品のレコメンド機能などがもっと充実していれば、方向性の近しい作品がランキング上位にひしめき合う状況が緩和されるのではとも感じています。
その辺りの機能改善は読者のUX向上につながりますし、埋もれていた作品を拾い上げることとなり作者様方もまた得をすることになるので、何とかなることを切に願っています。頑張ってほしい。ドハマりした作品がランキングに載らないまま完結したりして悲しい気持ちになったりとかよくあるので。

まあそんな感じで書きました。何か追加したい機能があればプルリクください。