[習作] Qiita用のChrome拡張を作ってみた

## はじめに

お勉強を兼ねて、非常に低機能な Qiita 用の Chrome 拡張を作成してみました。

## 機能

Qiita を使っていて、いくつか機能が追加されたらいいな、と思ったものがあります。

一つは、最近[削除](https://blog.qiita.com/close-item-list-page/)されてしまった「記事一覧」（新着一覧）の機能。

> Qiitaではサービス開始以降、記事投稿数の増加とともに、全ての記事に目を通すのは難しくなってきていました。
> また、ユーザーが多様化してきたことで、全ユーザー一律で新着記事を表示しても、欲しい情報に出会いづらくなっていました。
>
> そのため現在は、全ユーザーに一律の情報を見せるのではなく、ユーザー1人1人の興味に合わせて自分が欲しい情報に出会えるように、ホームフィードや、タイムラインフィードを用意しております。

運営側の思いはわかりますが、できれば広い範囲で情報をキャッチしたい、多少ノイズ（求めている情報ではないもの）があってもよいと思う時に、新着一覧はあってもよいかな、と個人的には思っています。

ちなみに、現在(2023年1月ごろ)の、1日あたりに新規作成される記事数は大体250～300記事程度でした。
タイトルだけ流し見して、気になったものだけ読むということは不可能ではない分量だと思います。

もう１つの機能は、ユーザーの記事一覧です。
例えば、今読んでいる記事を書いた人はほかにどんな記事を書いているんだろう？　という時に、その人の記事を「いいね」順ですぐに見られたら便利ではないかと思います。

これは検索機能で `user:ユーザー名` として、ソート順を「いいね」順にすれば今でも実現できますが、それをもっと素早く開けたらいいな、と思いました。

あるいは、自分の記事で人気のあるものはどれだろう、という見方もあるかもしれません。

また個人的には、自分でメモを兼ねて書いた記事を検索したいことがたまにあります。

これは [qiita-discussions](https://github.com/increments/qiita-discussions/) でも似たような話が何度か出ているので、欲しいのは私だけではないかと思います。

というわけで、上記の機能を Chrome 拡張で作ってみることにしました。

## Chrome 拡張の作り方

Chrome 拡張のオフィシャル情報は以下だと思います。

https://developer.chrome.com/docs/extensions/

まずは `manifest.json` を作ります。

現在サポートされている `manifest.json` のバージョンは 3 のみのようですが、個人ブログや Qiita に上がっている記事などはバージョン 2 のものも含まれているので、注意が必要です。

バージョン 3 のファイルフォーマットを参考に作ってみます。

https://developer.chrome.com/docs/extensions/mv3/manifest/

```json
{
    "name": "Qiita拡張[非公式]",
    "description": "Qiitaにちょっぴり機能を追加します",
    "version": "0.1.0",
    "manifest_version": 3,
    "icons": {
        "16": "icon-16.png"
    },
    "permissions": [
        "contextMenus"
    ],
    "background": {
        "service_worker": "background.js"
    }
}
```

前述した機能（新着一覧やユーザーの記事一覧）は、コンテキストメニュー（右クリックメニュー）に項目を追加する方針として、`permissions` には `contextMenus` を追加しました。

また、メニューを追加したり、一覧画面を開くためのロジックを書く JavaScript ファイル `background.js` を指定しました。

（おまけで、16x16 のアイコンを指定しました。これは緑色の「Ｑ」みたいなアイコンを手作りしました）

次に `background.js` です。

```Javascript
/** 過去日のラベルを作成 */
const createLabelPastArticles = (num) => {
    switch (num) {
        case 0: return '今日';
        case 1: return '昨日';
        default: return `${num}日前`;
    }
}

/** 指定された過去日を 'YYYY-MM-DD' 形式で返す */
const calcPastDate = (num) => {
    const day = new Date();
    day.setDate(day.getDate() - num);
    const year = day.getFullYear().toString().padStart(4, '0');
    const month = (day.getMonth() + 1).toString().padStart(2, '0');
    const date = day.getDate().toString().padStart(2, '0');
    return `${year}-${month}-${date}`;
}

/** ユーザー名が取得できない場合のデフォルト値 */
const DEFAULT_USERNAME = 'qiita';

/** Qiita の URL からユーザー名を抽出 */
const extractUserName = (url) => {
    if (!url) {
        return DEFAULT_USERNAME;
    }
    const array = url.match(/https?:\/\/qiita\.com\/([^\/\?]+)/);
    if (!array) {
        return DEFAULT_USERNAME;
    }
    return array[1];
}

// メニューの登録
chrome.runtime.onInstalled.addListener(() => {
    // 新着一覧
    chrome.contextMenus.create({
        type: 'normal',
        id: 'newArticles',
        title: '新着一覧',
        documentUrlPatterns: [
            '*://qiita.com/*'
        ]
    });
    // 日付ごとのグルーピング用
    chrome.contextMenus.create({
        type: 'normal',
        id: 'dailyArticles',
        title: '日付別新着',
        documentUrlPatterns: [
            '*://qiita.com/*'
        ]
    });
    // 今日～7日前の新着一覧
    [...Array(8)].forEach((_, i) => {
        chrome.contextMenus.create({
            type: 'normal',
            id: `${i}DayAgoArticles`,
            title: `${createLabelPastArticles(i)}の新着一覧`,
            parentId: 'dailyArticles'
        });
    });
    // ユーザーの記事（いいね順）
    chrome.contextMenus.create({
        type: 'normal',
        id: 'userArticles',
        title: 'このユーザーの記事（いいね順）',
        contexts: [ 'page', 'link' ],
        documentUrlPatterns: [
            '*://qiita.com/*'
        ]
    });
});

// メニュークリック時の処理
chrome.contextMenus.onClicked.addListener((item, tab) => {
    const url = new URL('https://qiita.com/search');
    if (item.menuItemId === 'newArticles') {
        url.searchParams.set('sort', 'created');
        url.searchParams.set('q', 'created:>1970-01-01');
    } else if (item.menuItemId.endsWith('DayAgoArticles')) {
        const num = parseInt(item.menuItemId);
        url.searchParams.set('sort', 'created');
        url.searchParams.set('q', 'created:' + calcPastDate(num));
    } else if (item.menuItemId === 'userArticles') {
        const user = extractUserName(item.linkUrl || item.pageUrl);
        url.searchParams.set('sort', 'like'); // いいね順
        url.searchParams.set('q', `user:${user}`);
    } else {
        return;
    }
    chrome.tabs.create({ url: url.href, index: tab.index + 1 });
});
```

JavaScript 的には難しいことは何もやっていないと思います。

コンテキストメニューの追加、およびメニューの実行については、以下の API リファレンスが参考になると思います。

https://developer.chrome.com/docs/extensions/reference/contextMenus/

一応（主に将来の自分のために）簡単に説明しておきます。

メニューの追加は `chrome.runtime.onInstalled.addListener()` に渡す関数内で実装します。

追加するメニュー項目ごとに `chrome.contextMenus.create()` を実行する必要があります。
渡すオブジェクトの仕様は上記 `contextMenus` の API リファレンスに詳細な説明があります。

今回は Qiita 専用の拡張機能とするため、`documentUrlPatterns` で、Qiita の URL を指定しているところが特徴だと思います。

また、単なる新着一覧（全記事一覧）だと、非常にたくさんの記事（2023年1月末時点で 80 万件以上）がヒットしてしまうため、日付ごとの新着記事一覧のメニューも用意しました。
今日～7日前の新着記事一覧を表示可能にしました。
そのためグルーピング用のメニューアイテムも追加しています。

ユーザーの記事一覧は、表示中の記事だけではなく、リンクを右クリックしたときにも表示可能としたいので `contexts` に `link` を追加しました（何も書かないと、デフォルトは `page` だけになるようです。詳細は API リファレンスを見てください）。

メニューが押された時の処理は `chrome.contextMenus.onClicked.addListener()` に渡す関数内で実装します。
作成時に指定したメニューID `id` が、 `item.menuItemId` で渡されるのでそれをもとに処理を分岐する必要があるようです。

新規一覧については、検索条件で全記事が検索対象になる条件として `created:>1970-01-01` で検索するページを、別タブで開くことにしています。

これは以下の記事の「おまけ」の部分を参考にしています。

https://qiita.com/rana_kualu/items/83dbe4c9972d2e255e22

また、日付ごとの新着一覧は検索条件として `created:YYYY-MM-DD` の形式で検索をしています。
実は Qiita の[検索オプションのヘルプ](https://help.qiita.com/ja/articles/qiita-search-options) には明記されていないのですが、`<` や `>` をつけずに（`=` や `==` などもつけずに）日付だけを検索すると、その日だけの検索結果になるようです。
（いわゆる undocumented な仕様なの？）

ユーザーの記事一覧は、コンテキストメニューが `link` か `page` かで取得元の URL を変えていますが（ `item.linkUrl || item.pageUrl` ）、URLからユーザー名を取得して、`user:ユーザー名` で検索しています。
この場合には、いいね順でソートしたいので、`sort=like` にしています。

ちなみに、厳密にユーザー名かどうかの判定はできないため、例えば `https://qiita.com/tags` を開いているときにこのメニューを選択すると、`user:tags` で検索してしまいますが、これは仕方がないものとして割り切ろうと思います。手抜きです。

またユーザー名が取得できないときには、デフォルトとして `user:qiita` で検索します（エラー処理とか面倒だから。超手抜き）。

## インストール方法

使いたい人がいるかどうかはわかりませんが、とりあえず以下のリポジトリに格納しています。

https://github.com/yoshi389111/qiita-chrome-extension

Store では公開していないため、各自でダウンロードして手動でインストールしてください。

1. 適当にダウンロード（かクローン）をして、ローカルの適当な場所においてください（必要であれば圧縮ファイルの展開なども）。
2. Chrome の「拡張機能」画面を開く（例：右上の三点メニュー→「その他のツール」→「拡張機能」など）
3. 「拡張機能」画面の右上の「デベロッパーモード」がonになっていることを確認（なっていなければクリックしてonにする）
4. 「拡張機能」画面の左上の「パッケージ化されていない拡張機能を読み込む」を選択して、上記でダウンロード（展開）したフォルダを指定

## 使い方

Qiita のサイトで、右クリックメニュー（コンテキストメニュー）を開くと「Qiita拡張[非公式]」というサブメニューがあるはず。 現状、以下の機能があります。

* 「新着一覧」：新しいタブが開き Qiita に投稿された全記事が新しい順に表示されます
* 「今日の新着一覧」～「7日前の新着一覧」：新しいタブが開き、指定の日に投稿された記事が新しい順に表示されます
* 「このユーザーの記事（いいね順）」：Qiitaの記事/ユーザーページでこのメニューを選択すると、新しいタブが開き、該当のユーザーの記事の一覧が、いいね順で表示されます

## インストール後に拡張機能を改造して読み込みなおしたい場合

見ての通り、簡単なソースなので適当に追加や改造するのも簡単だと思います。

改造した後に、Chrome 拡張を読み直したいときには、「拡張機能」画面を開き（上記参照）、「Qiita拡張[非公式]」と表示されている右下当たりのリロードボタン（矢印がくるっと丸くなっているもの）を押すと、良いようです（逆に言うと、最初に置いたフォルダをずっと覚えているので、注意してください）。

また、`console.log()` などで出力した場合、通常の console ではなく、Chrome拡張の DevTools の console に出力されるようです。
具体的には前述の「拡張機能」を開いて該当の Chrome 拡張の「ビューを検証」の横の「Service Worker」をクリックすると「DevTools」というウィンドウが開くと思うので、そちらの console を見てください。