LoginSignup
31
10

More than 1 year has passed since last update.

Webページ内の文章にキャラ語尾を追加するChrome拡張機能を作った

Last updated at Posted at 2021-12-21

作ったもの

Webページ内の文章にキャラ語尾を追加するChrome拡張機能を作りました。

以下のリンクからzipファイルをダウンロードし、パッケージ化されていない拡張機能を読み込むでzipファイル内のdist/を読み込むことで試すことができます。

拡張機能のアイコンをクリックし、テキストボックスに好きな語尾を入力して語尾を変更を押すと、Webページ内の文章に語尾が追加されます。

image.png

某イカ

image.png

某猫

image.png

某ロボット

image.png

某カエル

image.png

動機

  • TypeScriptで何か作りたい
  • Chrome Extensionで何か作りたい
  • 形態素解析で何かやりたい

技術

Chrome Extension

Chromeの拡張機能はJavaScript(TypeScript)で開発することができます。JavaScriptの場合、拡張機能の最小構成はプログラム本体と拡張機能の情報を記述するmanifest.jsonの2つのファイルになります。

今回はTypeScriptでChrome Extensionを開発するための便利そうなテンプレートを見つけたのでこちらを使わせて頂きました。TypeScript, Webpack, React, Jestの環境とサンプルコードが入っています。

ディレクトリ構成についてはこのリポジトリを参考にしてください。

形態素解析

Java製の日本語形態素解析エンジンであるkuromojiのJavaScript移植であるkuromoji.jsを使用しています。

以下のコマンドでインストールし、同梱されている辞書ファイルをpublic/に移動します。

$ npm install kuromoji @types/kuromoji
$ cp -a node_modules/kuromoji/dict public/

pathを使用するためpath-browserifyをインストールします。

$ npm install path-browserify @types/path-browserify

webpack.common.js, manifest.jsonに以下の記述を追加します。

webpack.common.js
const webpack = require("webpack");
const path = require("path");
const CopyPlugin = require("copy-webpack-plugin");
const srcDir = path.join(__dirname, "..", "src");

module.exports = {
    // 省略
    resolve: {
        extensions: [".ts", ".tsx", ".js"],
        fallback: {
            path: require.resolve("path-browserify")
        }
    },
    // 省略
};
manifest.json
  // 省略
  "web_accessible_resources": [
    "dict/*"
  ]
  // 省略

ローカルのファイルを読むため、辞書ファイルはchrome.extension.getURLで指定します。この時点で形態素解析を試してみると、開発者モードのConsoleで解析結果を確認することができます。

content_script.ts
import kuromoji from "kuromoji"
const path = require("path")

const text = "すもももももももものうち"
kuromoji.builder({ dicPath: chrome.extension.getURL(path.join(__dirname, "/dict")) }).build((error, tokenizer) => {
  if (error) {
    console.log(error)
  } else {
    const tokens = tokenizer.tokenize(text)
    console.log(tokens)
  }
}) 
0:
  basic_form: "すもも"
  conjugated_form: "*"
  conjugated_type: "*"
  pos: "名詞"
  pos_detail_1: "一般"
  pos_detail_2: "*"
  pos_detail_3: "*"
  pronunciation: "スモモ"
  reading: "スモモ"
  surface_form: "すもも"
  word_id: 415760
  word_position: 1
  word_type: "KNOWN"
  __proto__: Object
1:
  basic_form: "も"
  conjugated_form: "*"
  conjugated_type: "*"
  pos: "助詞"
  pos_detail_1: "係助詞"
  pos_detail_2: "*"
  pos_detail_3: "*"
  pronunciation: "モ"
  reading: "モ"
  surface_form: "も"
  word_id: 93220
  word_position: 4
  word_type: "KNOWN"
  __proto__: Object
2:
  basic_form: "もも"
  conjugated_form: "*"
  conjugated_type: "*"
  pos: "名詞"
  pos_detail_1: "一般"
  pos_detail_2: "*"
  pos_detail_3: "*"
  pronunciation: "モモ"
  reading: "モモ"
  surface_form: "もも"
  word_id: 1614710
  word_position: 5
  word_type: "KNOWN"
  __proto__: Object
3:
  basic_form: "も"
  conjugated_form: "*"
  conjugated_type: "*"
  pos: "助詞"
  pos_detail_1: "係助詞"
  pos_detail_2: "*"
  pos_detail_3: "*"
  pronunciation: "モ"
  reading: "モ"
  surface_form: "も"
  word_id: 93220
  word_position: 7
  word_type: "KNOWN"
  __proto__: Object
4:
  basic_form: "もも"
  conjugated_form: "*"
  conjugated_type: "*"
  pos: "名詞"
  pos_detail_1: "一般"
  pos_detail_2: "*"
  pos_detail_3: "*"
  pronunciation: "モモ"
  reading: "モモ"
  surface_form: "もも"
  word_id: 1614710
  word_position: 8
  word_type: "KNOWN"
  __proto__: Object
5:
  basic_form: "の"
  conjugated_form: "*"
  conjugated_type: "*"
  pos: "助詞"
  pos_detail_1: "連体化"
  pos_detail_2: "*"
  pos_detail_3: "*"
  pronunciation: "ノ"
  reading: "ノ"
  surface_form: "の"
  word_id: 93100
  word_position: 10
  word_type: "KNOWN"
  __proto__: Object
6:
  basic_form: "うち"
  conjugated_form: "*"
  conjugated_type: "*"
  pos: "名詞"
  pos_detail_1: "非自立"
  pos_detail_2: "副詞可能"
  pos_detail_3: "*"
  pronunciation: "ウチ"
  reading: "ウチ"
  surface_form: "うち"
  word_id: 62510
  word_position: 11
  word_type: "KNOWN"
  __proto__: Object

ポップアップ

拡張機能のアイコンをクリックすると出てくるやつです。

image.png

TSXで記述しています。Reactでは普通にテキストボックスを作成すると値が変更不可になってしまうため、useStateを用いて以下のようにすることで値の変更及び取得ができるようになります。またchrome.storage.local.setchrome.storage.local.getで5 MBまでローカルにデータの読み書きをすることができ、これをテキストボックスの内容の保存に使用しています。ストレージの利用には以下のようなmanifest.jsonへの記述が必要ですが、今回使用しているテンプレートでは既に記述されています。

manifest.json
  // 省略
  "permissions": [
    "storage"
  ],
  // 省略
popup.tsx
import React, { useEffect, useState } from "react"
import ReactDOM from "react-dom"

const Popup = () => {
  const [str, setStr] = useState<string>("")

  useEffect(() => {
    chrome.storage.local.get(
      {
        gobi: "にゃ",
      },
      (value) => {
        setStr(value.gobi)
      }
    )
  }, [])

  const changeGobi = () => {
    const gobi = (document.getElementById("gobi") as HTMLInputElement).value
    chrome.storage.local.set({ "gobi": gobi }, function () { })

    chrome.tabs.query({ active: true, currentWindow: true }, function (tabs) {
      const tab = tabs[0]
      if (tab.id) {
        chrome.tabs.sendMessage(
          tab.id,
          {
            gobi: gobi,
          }
        )
      }
    })
  }

  return (
    <div>
      <label>語尾</label>
      <input
        id="gobi"
        type="text"
        defaultValue={str}
        onChange={event => setStr(event.target.value)}
      />
      <button onClick={changeGobi}>語尾を変更</button>
    </div>
  )
}

ReactDOM.render(
  <React.StrictMode>
    <Popup />
  </React.StrictMode>,
  document.getElementById("root")
)

語尾の追加と文章の書き換え

innerTextの書き換えではDOM構造が破壊されうまくいかないので、Node.nodeTypeでテキストかどうかを判定しnode単位で書き換えを行うとうまくいきます。

語尾については追加するときの条件を悩んだ結果、n番目の形態素が「形容詞 or 名詞 or 感動詞 or 助動詞」かつn+1番目の形態素が「(なし) or 記号」のとき、またはn番目の形態素が基本形または命令形の「動詞」かつn+1番目の形態素が「(なし) or 記号」のときに語尾を追加としています。良い判定法を思いつかないので誰か助けて、プルリクお待ちしています。

content_script.ts
import kuromoji from "kuromoji"
const path = require("path")

const replaceDocument = (gobi: string) => {
  kuromoji.builder({ dicPath: chrome.extension.getURL(path.join(__dirname, "/dict")) }).build((error, tokenizer) => {
    if (error) {
      console.log(error)
    } else {
      const elementsInsideBody = [...document.body.getElementsByTagName("*")]

      elementsInsideBody.forEach(element => {
        element.childNodes.forEach(child => {
          if (child.nodeType === 3) {
            const value = child.nodeValue
            let value_new = ""
            const tokens = tokenizer.tokenize(value!)

            // 語尾を付与
            for (let i = 0; i < tokens.length; i++) {
              const token = tokens[i]
              const token_next = tokens[i + 1]
              if ((["形容詞", "名詞", "感動詞", "助動詞"].includes(token.pos)) && ((token_next == undefined) || (token_next.pos == "記号"))) {
                value_new += token.surface_form + gobi
              } else if ((token.pos == "動詞") && (token.conjugated_form == "基本形") && ((token_next == undefined) || (token_next.pos == "記号"))) {
                value_new += token.surface_form + gobi
              } else if ((token.pos == "動詞") && (~token.conjugated_form.indexOf("命令")) && ((token_next == undefined) || (token_next.pos == "記号"))) {
                value_new += token.surface_form + gobi
              } else {
                value_new += token.surface_form
              }
            }
            child.nodeValue = value_new
          }
        })
      })
    }
  })
}

chrome.runtime.onMessage.addListener(function (msg, sender, sendResponse) {
  if (msg.gobi) {
    replaceDocument(msg.gobi)
  }
})

感想

語尾を追加する部分が予想以上に面倒でした。

31
10
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
31
10