作ったもの
Webページ内の文章にキャラ語尾を追加するChrome拡張機能を作りました。
以下のリンクからzipファイルをダウンロードし、パッケージ化されていない拡張機能を読み込むでzipファイル内のdist/
を読み込むことで試すことができます。
拡張機能のアイコンをクリックし、テキストボックスに好きな語尾を入力して語尾を変更を押すと、Webページ内の文章に語尾が追加されます。
某イカ
某猫
某ロボット
某カエル
動機
- 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
に以下の記述を追加します。
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")
}
},
// 省略
};
// 省略
"web_accessible_resources": [
"dict/*"
]
// 省略
ローカルのファイルを読むため、辞書ファイルはchrome.extension.getURL
で指定します。この時点で形態素解析を試してみると、開発者モードのConsoleで解析結果を確認することができます。
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
ポップアップ
拡張機能のアイコンをクリックすると出てくるやつです。
TSXで記述しています。Reactでは普通にテキストボックスを作成すると値が変更不可になってしまうため、useState
を用いて以下のようにすることで値の変更及び取得ができるようになります。またchrome.storage.local.set
とchrome.storage.local.get
で5 MBまでローカルにデータの読み書きをすることができ、これをテキストボックスの内容の保存に使用しています。ストレージの利用には以下のようなmanifest.json
への記述が必要ですが、今回使用しているテンプレートでは既に記述されています。
// 省略
"permissions": [
"storage"
],
// 省略
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 記号」のときに語尾を追加としています。
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)
}
})
感想
語尾を追加する部分が予想以上に面倒でした。