見たくないテキストをSCPっぽく隠してくれるChrome拡張機能[データ削除済み]

意図せずネガティブな記事やコメントを見てしまい気分が悪くなることはありませんか？ そんな問題を解決するために、見たくない単語を画面上から隠してくれる拡張機能を作りました。

この記事では作成した拡張機能の紹介と、実装にあたっての技術的な話を書きたいと思います。

## 作ったもの
- [[データ削除済み] - Chrome ウェブストア](https://chrome.google.com/webstore/detail/data-expunged/lmoeanpjjaliocmmbjloeiccpkoccado?hl=ja)
- [tackme31/data-expunged: A Google Chrome extension to hide text like SCP format.](https://github.com/tackme31/data-expunged)

オプションで単語を登録しておくと、その単語を含んだHTMLタグを「`███`」や「`[データ削除済み]`」といった文字列に置き換えてくる拡張機能です。以下はWikipediaで使用した際のサンプルです。

![sample.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/754361/5930eafe-f015-75e5-1ccf-fe289e9b5278.png)

### オプション
隠したい単語はオプションページで設定することができます。以下の設定を用意しています。

|設定|説明|
|:-|:-|
|検閲する単語|画面上から隠したい単語を設定できます。`div`, `blockquote`, `p`, `td`, `li`は`[データ削除済み]`に、それ以外は黒塗り（`███`）に置換します。|
|除外する単語|検閲する単語を含んだHTMLタグであっても、ここで設定した単語が含まれていれば検閲対象外にします。|
|CSSセレクター|検閲対象のHTMLタグをCSSセレクターで指定できます。|
|対象サイト|拡張機能を有効化・無効化するサイトを指定できます。デフォルトでは全てのサイトが対象です。|

オプションページはSCPサイトっぽい感じに仕上げてみました。

![options.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/754361/f38fd71d-54c1-dd95-1415-4432731d17a3.png)


### クリックで原文を表示
検閲された箇所をクリックすると元のHTMLタグを表示できます。

![clickunmask.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/754361/6634cdee-6448-8e2e-c2b9-d490adf426c3.gif)


### 設定のエクスポート・インポート
オプションページの左メニューから、設定のエクスポートとインポートができます。

![](./importexport.png)![importexport.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/754361/50995887-6e3d-ef5b-aa69-c14e1970f426.png)


### 右クリックから単語を追加
右クリックで選択した単語を検閲対象に追加できます。追加後は画面リフレッシュする必要があります。

![](./contextmenu.gif)![contextmenu.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/754361/10296783-8f0a-b569-cd8c-e65cef33d6fa.gif)


### 閲覧中のページで拡張機能を無効化
画面右上の拡張機能アイコンから、現在見ているサイトを検閲の対象から外すことができます。

![](./currentsite.png)![currentsite.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/754361/a39b95fe-8ab1-64e9-8beb-d67cf949f59e.png)

## 技術的な話
### 開発環境の構築
[vitesse-webext](https://github.com/antfu/vitesse-webext)というVite + Vue + TypeScriptでブラウザ拡張機能を作成できるテンプレートがあるのですが、今回はそのReact版のwebext-templateというテンプレートを使用させていただきました。

- [neo-hack/webext-template: a fork version of vitesse-webext, but with react](https://github.com/neo-hack/webext-template)

Viteを使用しているので高速なビルドやHMR（リアルタイムで変更が反映される）ができて非常に便利です。

### HooksでストレージAPIにアクセスする
直接`browser.storage`APIを使用してもいいですが、`useChromeStorage`という便利なHooksを見つけたのでこちらを利用させていただきました。

- [onikienko/use-chrome-storage: React hooks for chrome.storage. You may use it for keeping global state in chrome extensions.](https://github.com/onikienko/use-chrome-storage)

以下のように`useState`と同じような感覚でストレージにアクセスできます。

```tsx
import { useChromeStorageLocal } from 'use-chrome-storage'

const MyComponent = () => {
  const [count, setCount] = useChromeStorageLocal('count', 0);

  return (
    <button onClick={() => setCount(count + 1)}>
      Count: {count}
    </button>
  )
}
```

ただしこのライブラリで定義されているものには型がついていなかったので、以下のようなラップしたHooksを定義して使用しました。

```ts:src/logic/storage.ts
import { useChromeStorageLocal } from "use-chrome-storage";

export function useChromeStorage<T>(
  key: string,
  initialValue: T
): [T, (value: T) => void, boolean, string] {
  return useChromeStorageLocal(key, initialValue);
}
```

### manifest.jsonのバージョンアップ
2022/1/17より、Chromeウェブストアへ新規申請するにはmanifest.json v3が必須になりました。今回使用したテンプレートではv2を使用しているので、これをv3に対応させる必要があります。

まず[公式ドキュメント](https://developer.chrome.com/docs/extensions/mv3/mv3-migration-checklist/)を参考にv3へバージョンアップしてみたのですが、オプションページでHMRが動作しなくなりました。HMRを行うためにオプションページでlocalhostのリソースを読み込んでいるのですが、どうやらそれがCSPで弾かれていたようです。

CSPの設定はmanifest.jsonで変更できるのですが、Chromiumの不具合でv3ではCSPが無視されてしまうためどうしようもありません。

- [1247690 - Manifest CSP ignored in MV3 Extension Pages](https://bugs.chromium.org/p/chromium/issues/detail?id=1247690)

仕方がないので、デバッグビルドではv2を、リリースビルドではv3を出力するようにします。
以下のリポジトリに対応したコードがあるのでよければ参考にしてください。

- [tackme31/webext-template at manifest-v3](https://github.com/tackme31/webext-template/tree/manifest-v3)

まずはbackgroundスクリプトをv3に対応させます。
このテンプレートではbackgroundにindex.htmlを指定し、そこからスクリプトを読み込むことでHMRを実現しています。v3ではbackgroundにhtmlファイルを指定できなくなったので、viteのビルド設定を変えてv3用のスクリプトを出力するようにします。

vite.config.tsを修正して、デバッグビルド時だけindex.htmlを出力するようにします。

```diff:vite.config.ts
  rollupOptions: {
    input: {
-     background: r('src/background/index.html'),
+     ...(isDev ? {background: r('src/background/index.html')} : {})
      options: r('src/options/index.html'),
      popup: r('src/popup/index.html'),
    },
  },
```

vite.config.content.tsをコピーして、vite.config.background.tsを作成します。

```ts:vite.config.background.ts
import { defineConfig } from 'vite'
import { sharedConfig } from './vite.config'
import { r, isDev } from './scripts/utils'

// bundling the background script using Vite
export default defineConfig({
  ...sharedConfig,
  build: {
    watch: isDev
      ? {
          include: [r('src/background/**/*')],
        }
      : undefined,
    outDir: r('extension/dist/background'),
    cssCodeSplit: false,
    emptyOutDir: false,
    sourcemap: isDev ? 'inline' : false,
    lib: {
      entry: r('src/background/main.ts'),
      formats: ['es'],
    },
    rollupOptions: {
      output: {
        entryFileNames: 'index.global.js',
      },
    },
  },
  plugins: [
    ...sharedConfig.plugins!,
  ],
})
```

package.jsonを修正し、`pnpm build:js`でvite.config.background.tsをビルドするようにします。

```diff:package.json
  "build:web": "vite build",
- "build:js": "vite build --config vite.config.content.ts",
+ "build:js": "vite build --config vite.config.content.ts && vite build --config vite.config.background.ts",
  "pack": "cross-env NODE_ENV=production run-p pack:*",
```

最後にsrc/manifest.tsを修正し、ビルドに応じて出力するmanifest.jsonを切り替えるようにします。

```ts:src/manifest.ts
import fs from 'fs-extra'
import type { Manifest } from 'webextension-polyfill'
import type PkgType from '../package.json'
import { isDev, port, r } from '../scripts/utils'

type ManifestV3 = Manifest.WebExtensionManifest & {
  host_permissions?: string[]
}

// v2, v3で共通のプロパティ
const getSharedManifest = async () => {
  const pkg = (await fs.readJSON(r('package.json'))) as typeof PkgType
  const manifest: Partial<ManifestV3> = {
    name: pkg.name,
    version: pkg.version,
    description: pkg.description,
    options_ui: {
      page: './dist/options/index.html',
      open_in_tab: true,
    },
    icons: {
      16: './assets/icon-512.png',
      48: './assets/icon-512.png',
      128: './assets/icon-512.png',
    },
    content_scripts: [
      {
        matches: ['http://*/*', 'https://*/*'],
        js: ['./dist/contentScripts/index.global.js'],
      },
    ],
  }

  return manifest;
}

export async function getManifest() {
  const manifest = await getSharedManifest();
  const browserAction = {
    default_icon: './assets/icon-512.png',
    default_popup: './dist/popup/index.html',
  }
  const permissions = {
    type: ['tabs', 'storage', 'activeTab'],
    host: ['http://*/', 'https://*/']
  };

  if (isDev) {
    // デバッグビルド
    return {
      ...manifest,
      manifest_version: 2,
      browser_action: browserAction,
      background: {
        page: './dist/background/index.html',
        persistent: false,
      },
      permissions: [
        ...permissions.type,
        ...permissions.host,
        'webNavigation'
      ],
      options_ui: {
        ...manifest.options_ui,
        chrome_style: false,
      },
      content_security_policy: `script-src \'self\' http://localhost:${port}; object-src \'self\'`
    }

  } else {
    // リリースビルド
    return {
      ...manifest,
      manifest_version: 3,
      action: browserAction,
      background: {
        service_worker: './dist/background/index.global.js'
      },
      permissions: permissions.type,
      host_permissions: permissions.host
    }
  }
}
```

これでHMRによるデバッグ機能を維持しつつ、v3に対応することができました。

### 多言語化に対応する
このテンプレートは多言語化用のファイル（_locales/*/messages.json）を含んでいないため、以下の手順で多言語化できるようにしました。

１. publicフォルダ下に_locales/*/message.jsonを作成
２. scripts/prepare.tsでビルド時に_localesフォルダをコピーするよう修正

```diff:scripts/prepare.ts
  function copyPublicAssets() {
    fs.copy(r('public/assets'), r('extension/assets'))
  }
+
+ function copyPublicLocales() {
+   fs.copy(r('public/_locales'), r('extension/_locales'))
+ }

  function writeManifest() {
    execSync('npx esno ./scripts/manifest.ts', { stdio: 'inherit' })
  }

  writeManifest()
  copyPublicAssets()
+ copyPublicLocales()
```

３. src/manifest.tsに`default_locale`を追加

```diff:src/manifest.ts
const manifest: Manifest.WebExtensionManifest = {
  manifest_version: 2,
  name: pkg.displayName || pkg.name,
+ default_locale: 'en',
  version: pkg.version,
  description: pkg.description,
```

これで`browser.i18n.getMessage`で多言語化できるようになります。

ただし**manifest v3ではbackgroundスクリプト内で`browser.i18n`を利用することができません**。[こちらの記事](https://cofus.blog/posts/when-chromei18ngetmessage-cannot-be-obtained-with-service-worker)を参考に、以下のような関数を用意して多言語化に対応しました。

```ts:i18n.ts
import en from "../../public/_locales/en/messages.json";
import ja from "../../public/_locales/ja/messages.json";

const messages = { en, ja };
type Language = keyof typeof messages;
type MessageKey = keyof typeof en & keyof typeof ja;

export const getMessage = (key: MessageKey) => {
  const lang = navigator.language.slice(0, 2) as Language;
  return messages[lang || 'en'][key].message;
};
```

さらにオプションページのページタイトルについてはindex.htmlのheadタグ内に直接記載する必要があるため多言語化ができません。今回はオプションページのコンポーネントマウント時に動的に設定することで対応しました。

```tsx:src/Options/main.tsx
import React, { useEffect } from "react";
import ReactDOM from "react-dom";
import "virtual:windi.css";
import { Options } from "./Options";

const App = () => {
  useEffect(() => {
    // マウント後にタイトルを多言語化
    document.title = browser.i18n.getMessage("option_title");
  }, []);

  return (
    <React.StrictMode>
      <Options />
    </React.StrictMode>
  );
};

ReactDOM.render(App, document.getElementById("root"));
```

## 終わりに
Chrome拡張機能は以前作ったことがあるのですが、React + TypeScriptで作成したのは今回が初めてでした。muiを使えばオプションページがサクッと作れて便利ですね。
あとViteを使ったのも初めてだったのですが拡張機能の開発でHMRが使えるのには感動しました。わざわざビルドして更新ボタンを押してという作業が減ってめちゃくちゃ開発が楽でした。manifest v3でも使いたいので早く不具合直ってほしいですね。

ちなみにこの拡張機能よりもっと高性能なCustomBlockerという拡張機能があるみたいですね（作ってから知りました）。

- [CustomBlocker - Chrome ウェブストア](https://chrome.google.com/webstore/detail/customblocker/elnfhbjabfcepfnaeoehffgmifcfjlha?hl=ja)

あえて[データ削除済み]を使う理由があるとすれば、SCP職員気分を味わえて楽しいこと、くらいでしょうか。あと置換箇所をクリックすると元のテキストが見られるのも個人的お気に入りポイントです。



まだ作ったばかりであまり機能は多くないですが、今後も改善を続けていく予定なのでよければ使ってください。