#97　【Chrome拡張機能】ダウンロードフォルダの整理を便利にする拡張機能を作ってみる

## はじめに
ダウンロードフォルダを散らかしたくない...
そんな個人的な悩みを解決するためにChromeの拡張機能でダウンロードを管理しようと思い作ってみました。

## 要件
作成する拡張機能でやりたいこと、
1. コンテキストメニューでページ内情報 or 手動入力文字でフォルダを作成
2. ①で作成したフォルダ内にページへのショートカットを作成
3. ダウンロード時にページ内の情報 or 手動入力文字でフォルダを作成
4. ③で作成したフォルダにダウンロードファイルを格納
5. ②同様、フォルダ内にページへのショートカットを作成

以上の要件で進め、ダウンロード後のフォルダを以下のようにして整理を楽にしようという想定です。

```
ダウンロード/
        ├ [ページ情報 or 入力値]/
        │                ├ ダウンロードファイル
        │                └ ダウンロード元へのショートカット
        └ [ページ情報 or 入力値]/
                        ├ ダウンロードファイル
                        └ ダウンロード元へのショートカット
```

## 前提
本シリーズは3部構成予定です。
具体的には、
1. 本記事で①～②
2. 次回で③～⑤
3. フォルダ名の決定を手動入力でもできるようにする
という流れで進めていく予定です。

## ディレクトリ構成
今回で作成するコードの最終的なディレクトリ構成は以下の通りです。
```
拡張機能名/
        ├ background.js
        ├ content.js
        └ manifest.json
```

## manifest.js
Chromeの拡張機能を作成する際に必ず含めなければいけないファイルです。
今回、名前やバージョン、説明の他にも記述しているオプションがあるので見ていきます。

```json
{
        "name": "拡張機能名(任意の名前)",
        "version": "1.0.0",
        "manifest_version": 3,
        "description": "拡張機能説明(任意の説明)",
        "action": {},
        "background": {
                "service_worker": "background.js"
        },
        "content_scripts": [
                {
                        "js": ["content.js"],
                        "matches": ["https://*/*"]
                }
        ],
        "permissions": ["contextMenus", "downloads"]
}
```
### background
```json
{
        "background": {
                "service_worker": "background.js"
        },
}
```
拡張機能で全体的な機能の維持や操作の管理を行う箇所です。
今回、コンテキストメニューの作成や作成したコンテキストメニューのイベントを記述する部分でもあります。
参照:https://developer.mozilla.org/ja/docs/Mozilla/Add-ons/WebExtensions/manifest.json/background

### content_script
```json
{
        "content_scripts": [
                {
                        "js": ["content.js"],
                        "matches": ["https://*/*"]
                }
        ],
}
```
設定したURLのパターンにマッチしているページに記述したスクリプトをロードすることを明示する記述です。
今回は「backgroundからのイベントに応じて、イベント実行時のページ情報を返す」という処理を記述していきます。
参照:https://developer.mozilla.org/ja/docs/Mozilla/Add-ons/WebExtensions/manifest.json/content_scripts

### permissions
```json
{
        "permissions": ["contextMenus", "donloads"]
}
```
記述した権限を「この拡張機能で使用します」という宣言になります。
この宣言を正しく行わなかった場合、公開されている拡張機能では警告等が通達される場合もあるので注意が必要です。
参照:https://developer.chrome.com/docs/extensions/reference/api/permissions?hl=ja

***

以上、ここまでが `manifest.json` の記述についての説明でした。
次は、`background.js` に移っていきます。

## background.js
先ほど先述した通り、拡張機能全体の機能に対する維持や管理を行う処理を記述する箇所になります。
以下全体像のコードとなりますが、コメント毎に何をしているのかについてもう少し詳しく説明していきます。

```javascript:background.js
// contextMenuを作成
chrome.runtime.onInstalled.addListener(() => {
        chrome.contextMenus.create({
                id: "downloadShortcut",
                title: "ショートカットをダウンロード",
        });
});

// contextMenuクリック時のイベント
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
        if (info.menuItemId === "downloadShortcut") {
                const shortcutInfo = await chromeSendMessage(tab.id, { type: "getShortcutInfo" });
                downloadShortcut(shortcutInfo);
                // 使用し終えたblobURLは解放する
                chromeSendMessage(tab.id, {
                        type: "revokeBlobUrl",
                        url: shortcutInfo.url,
                });
        }
});

// メッセージを送る
const chromeSendMessage = async (tabId, message) => {
        return await chrome.tabs.sendMessage(tabId, message);
};

// shortcutのダウンロード
const downloadShortcut = (shortcutInfo) => {
        chrome.downloads.download({
                url: shortcutInfo.url,
                filename: shortcutInfo.filename + "/" + shortcutInfo.filename + ".url",
        });
};
```
### contextMenu
```javascript:background.js
// contextMenuを作成
chrome.runtime.onInstalled.addListener(() => {
        chrome.contextMenus.create({
                id: "downloadShortcut",
                title: "ショートカットをダウンロード",
        )};
});

// contextMenuクリック時のイベント
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
        if (info.menuItemId === "downloadShortcut") {
                const shortcutInfo = await chromeSendMessage(tab.id, { type: "getShortcutInfo" });
                downloadShortcut(shortcutInfo);
                // 使用し終えたblobURLは解放する
                chromeSendMessage(tab.id, {
                        type: "revokeBlobUrl",
                        url: shortcutInfo.url,
                });
        }
});
```
上記コードは「コンテキストメニューの作成、コンテキストメニューの登録」ということを行っています。
コンテキストメニューを作成する際に `id` を登録できるので、複数のメニューを操作する際にはイベント側で `info.menuItemId` を参照し適切に処理を分けることが重要となります。
参照:https://developer.chrome.com/docs/extensions/develop/ui/context-menu?hl=ja
参照:https://developer.chrome.com/docs/extensions/reference/api/contextMenus?hl=ja

### sendMessage
下記コードは先ほどのメニュー実行時に呼び出されていた関数の1つです。
```javascript:background.js
// メッセージを送る
const chromeSendMessage = async (tabId, message) => {
        return await chrome.tabs.sendMessage(tabId, message);
};
```
このコードは `chrome.tabs.sendMessage()` で `content_script` に対し任意のメッセージを送り、`content_script` 側で適切な処理を施し戻り値を受け取るコードです。
実際にメニュー実行時には `content_script` からショートカット情報を受け取り、使用し終えた情報をメモリから解放する、といった用途で使用しています。
参照:https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/sendMessage

### downloads
```javascript:background.js
// shortcutのダウンロード
const downloadShortcut = (shortcutInfo) => {
        chrome.downloads.download({
                url: shortcutInfo.url,
                filename: shortcutInfo.filename + "/" + shortcutInfo.filename + ".url",
        });
};
```
この記述では `downlaods` apiを使用して `content_script` 側から受け取ったショートカット情報を使用、ショートカットのダウンロードを行っています。
また、ここで `フォルダ/ショートカット.url` となるようにファイルネームを設定しています。
参照:https://developer.chrome.com/docs/extensions/reference/api/downloads?hl=ja

___

以上が今回記述した `background.js` での記述です。
流れとしてはコンテキストメニューの作成とイベントの登録、 `content_script` へ情報を送り戻り値を返却してもらい、その値を元にダウンロード処理を行う、という流れです。

## content.js
以下全体像のコードとなりますが、やっていることは単純で `background` から `sendMessage` で送られた情報を `onMessage` で受け取り、適切な値を `sendResponse` で返却するということを行っています。
また `background` 同様コメント毎に説明していきます。

```javascript:content.js
// メッセージを受け取る
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
        if (request.type === "getShortcutInfo") {
                // ページ内の h1タグ or titleタグ からファイル名候補を取得する
                const pageTitle =
                        (document.querySelector("h1") || document.querySelector("title"))
                        .textContent;
                // shortcutのBlobURLと、filenameを返却する
                sendResponse({
                        url: URL.createObjectURL(createShortcutBlob(document.URL)),
                        filename: charEscapeToFilename(pageTitle),
                });
        }

        // 生成したshortcutBlobURLを解放する
        if (request.type === "revokeBlobUrl") {
                URL.revokeObjectURL(request.url);
        }
});

// shortcutのBlobを作成
const createShortcutBlob = (pageUrl) => {
        // [InternetShortcut]
        // URL=ページURL
        const text = "[InternetShortcut]" + "\n" + "URL=" + pageUrl;
        return new Blob([text], { type: "text/plan" });
};

//　入力文字をファイルやフォルダ名で扱える文字にエスケープ
const charEscapeToFilename = (char) => {
        const escapeChar = char
                .replace(/\\/g, "")
                .replace(/\//g, "")
                .replace(/:/g, "")
                .replace(/\*/g, "")
                .replace(/"/g, "")
                .replace(/</g, "")
                .replace(/>/g, "")
                .replace(/\|/g, "")
        return escapeChar.trim();
};
```

### onMessage
先述したとおり、`background` から送信された値を適切な処理に振り分け `sendResponse` で返す、ということを行っています。
主な動きはページ情報の h1 と title を参照し、フォルダやファイルで扱える文字にエスケープ処理を施し返却することや、ショートカットファイルのダウンロード用URLを発行し返却しています。
```javascript:content.js
// メッセージを受け取る
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
        if (request.type === "getShortcutInfo") {
                // ページ内の h1タグ or titleタグ からファイル名候補を取得する
                const pageTitle =
                        (document.querySelector("h1") || document.querySelector("title"))
                        .textContent;
                // shortcutのBlobURLと、filenameを返却する
                sendResponse({
                        url: URL.createObjectURL(createShortcutBlob(document.URL)),
                        filename: charEscapeToFilename(pageTitle),
                });
        }

        // 生成したshortcutBlobURLを解放する
        if (request.type === "revokeBlobUrl") {
                URL.revokeObjectURL(request.url);
        }
});
```
この際、発行したURLは明示的に解放するということも忘れずに行います。
参照:https://developer.mozilla.org/ja/docs/Mozilla/Add-ons/WebExtensions/API/runtime/onMessage
参照:https://developer.mozilla.org/ja/docs/Web/API/URL/revokeObjectURL_static

### Blobの作成
```javascript:content.js
// shortcutのBlobを作成
const createShortcutBlob = (pageUrl) => {
        // [InternetShortcut]
        // URL=ページURL
        const text = "[InternetShortcut]" + "\n" + "URL=" + pageUrl;
        return new Blob([text], { type: "text/plan" });
};
```
上記コードではショートカットファイルを作成するためにBlobにURLを書き込み作成しています。
コメントにも記載していますが、実態は以下のような内容のファイルとなっています。
```txt
[InternetShortcut]
URL=ページURL
```
このBlobを `URL.createObjectURL` でアクセスできるURLを発行しダウンロードしています。
参照:https://developer.mozilla.org/ja/docs/Web/API/URL/createObjectURL_static

### エスケープ
h1 や title に含まれる文字をファイルやフォルダで扱える文字としてエスケープする関数です。
```javascript:content.js
const charEscapeToFilename = (char) => {
        const escapeChar = char
                .replace(/\\/g, "")
                .replace(/\//g, "")
                .replace(/:/g, "")
                .repalce(/\*/g, "")
                .repalce(/"/g, "")
                .replace(/</g, "")
                .replace(/>/g, "")
                .replace(/\|/g, "")
        return escapeChar.trim();
};
```
以上が今回実装した全体のコードと各関数などの説明でした。

## 動作
ここまで書いた拡張機能の動作を確認してみるために拡張機能をインストールしてみましょう。
1. 拡張機能の管理
2. デベロッパーモードON
3. パッケージ化されていない拡張機能を読み込む
とすることで拡張機能を読み込むことができます。

拡張機能をONにした状態で適当なページ上で 右クリック=>ショートカットをダウンロード をクリックすることで該当ページの h1 or title 情報でフォルダが作成され、その中に該当ページへのショートカットが保存されていることが確認できると思います。

## さいごに
今回は、コンテキストメニューを作成し、メニューのイベントで該当ページの情報フォルダを作成、ショートカットをダウンロードする、という所まで実装できました。
次回は実際にファイルをダウンロードした際に今回実装した内容と合わせて理想とするフォルダ構成となるように実装を進めます。
ここまでお読みいただきありがとうございました。