25
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Posted at

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

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

作ったもの

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

sample.png

オプション

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

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

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

options.png

クリックで原文を表示

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

clickunmask.gif

設定のエクスポート・インポート

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

importexport.png

右クリックから単語を追加

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

contextmenu.gif

閲覧中のページで拡張機能を無効化

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

currentsite.png

技術的な話

開発環境の構築

vitesse-webextというVite + Vue + TypeScriptでブラウザ拡張機能を作成できるテンプレートがあるのですが、今回はそのReact版のwebext-templateというテンプレートを使用させていただきました。

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

HooksでストレージAPIにアクセスする

直接browser.storageAPIを使用してもいいですが、useChromeStorageという便利なHooksを見つけたのでこちらを利用させていただきました。

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

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

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

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

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

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に対応させる必要があります。

まず公式ドキュメントを参考にv3へバージョンアップしてみたのですが、オプションページでHMRが動作しなくなりました。HMRを行うためにオプションページでlocalhostのリソースを読み込んでいるのですが、どうやらそれがCSPで弾かれていたようです。

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

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

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

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

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を作成します。

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をビルドするようにします。

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を切り替えるようにします。

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)を含んでいないため、以下の手順で多言語化できるようにしました。

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

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()

3. src/manifest.tsにdefault_localeを追加

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を利用することができませんこちらの記事を参考に、以下のような関数を用意して多言語化に対応しました。

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タグ内に直接記載する必要があるため多言語化ができません。今回はオプションページのコンポーネントマウント時に動的に設定することで対応しました。

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という拡張機能があるみたいですね(作ってから知りました)。

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

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

25
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
25
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?