Japanese Text Analyzer
by Mistizz
Verified
#!/usr/bin/env node
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import * as fs from 'fs';
import * as path from 'path';
import { createRequire } from 'module';
import { fileURLToPath } from 'url';
import { z } from 'zod';
const require = createRequire(import.meta.url);
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// kuromojiをrequireで読み込む
const kuromoji = require('kuromoji');
// グローバル変数で形態素解析器の状態を管理
let tokenizerInstance = null;
let initializingPromise = null;
let initializationError = null;
let tokenizerReady = false;
// 辞書パスを見つける関数
function findDictionaryPath() {
// 考えられる辞書パスの候補を配列で定義
const possiblePaths = [
// 1. require.resolveを使用してkuromojiのパスを見つける
(() => {
try {
// kuromojiモジュールのルートパスを取得
const kuromojiPath = path.dirname(require.resolve('kuromoji/package.json'));
return path.join(kuromojiPath, 'dict');
} catch (e) {
console.error(`require.resolveエラー: ${e}`);
return null;
}
})(),
// 2. 実行ファイルからの相対パス
path.resolve(__dirname, '../node_modules/kuromoji/dict'),
// 3. カレントディレクトリからの相対パス
path.resolve('./node_modules/kuromoji/dict'),
// 4. プロセスのカレントワーキングディレクトリからの相対パス
path.resolve(process.cwd(), 'node_modules/kuromoji/dict'),
// 5. グローバルnpmモジュールからの相対パス(Unixの場合)
process.env.HOME ? path.resolve(process.env.HOME, '.npm/kuromoji/dict') : null,
// 6. npmのキャッシュディレクトリ
process.env.npm_config_cache ? path.resolve(process.env.npm_config_cache, 'kuromoji/dict') : null,
].filter(Boolean); // nullやundefinedをフィルタリング
// 各パスが存在するか確認し、最初に見つかったものを返す
for (const dicPath of possiblePaths) {
try {
// base.dat.gzが存在するか確認
if (dicPath && fs.existsSync(path.join(dicPath, 'base.dat.gz'))) {
console.error(`辞書パスが見つかりました: ${dicPath}`);
return dicPath;
}
} catch (e) {
// エラーが発生した場合は次のパスを試す
console.error(`パス確認エラー(${dicPath}): ${e}`);
continue;
}
}
// 見つからなかった場合はデフォルトパスを返す
console.error('辞書パスが見つかりませんでした。デフォルトパスを使用します。');
return './node_modules/kuromoji/dict';
}
// 形態素解析器を初期化する関数
async function initializeTokenizer() {
// すでに初期化されている場合
if (tokenizerInstance) {
return tokenizerInstance;
}
// 初期化中の場合は既存のPromiseを返す
if (initializingPromise) {
return initializingPromise;
}
console.error('形態素解析器の初期化を開始...');
// 辞書パスを取得
const dicPath = findDictionaryPath();
console.error(`使用する辞書パス: ${dicPath}`);
// 初期化処理をPromiseでラップ
initializingPromise = new Promise((resolve, reject) => {
try {
kuromoji.builder({ dicPath }).build((err, tokenizer) => {
if (err) {
console.error(`形態素解析器の初期化エラー: ${err.message || err}`);
initializationError = err;
initializingPromise = null; // リセットして再試行できるようにする
tokenizerReady = false;
reject(err);
return;
}
console.error('形態素解析器の初期化が完了しました');
tokenizerInstance = tokenizer;
tokenizerReady = true;
resolve(tokenizer);
});
} catch (error) {
console.error(`形態素解析器の初期化中に例外が発生: ${error.message || error}`);
initializationError = error;
initializingPromise = null; // リセットして再試行できるようにする
tokenizerReady = false;
reject(error);
}
});
return initializingPromise;
}
// ファイルパスを解決する関数 - WindowsとWSL/Linux形式の両方に対応
function resolveFilePath(filePath: string): string {
try {
// WSL/Linux形式のパス (/c/Users/...) をWindows形式 (C:\Users\...) に変換
if (filePath.match(/^\/[a-zA-Z]\//)) {
// /c/Users/... 形式を C:\Users\... 形式に変換
const drive = filePath.charAt(1).toUpperCase();
let windowsPath = `${drive}:${filePath.substring(2).replace(/\//g, '\\')}`;
console.error(`WSL/Linux形式のパスをWindows形式に変換: ${filePath} -> ${windowsPath}`);
if (fs.existsSync(windowsPath)) {
console.error(`変換されたパスでファイルを発見: ${windowsPath}`);
return windowsPath;
}
}
// 通常の絶対パスの処理
if (path.isAbsolute(filePath)) {
if (fs.existsSync(filePath)) {
console.error(`絶対パスでファイルを発見: ${filePath}`);
return filePath;
}
// 絶対パスでファイルが見つからない場合はエラー
throw new Error(`指定された絶対パス "${filePath}" が存在しません。パスが正しいか確認してください。` +
` Windows形式(C:\\Users\\...)かWSL/Linux形式(/c/Users/...)で指定してください。`);
}
// 相対パスの場合、カレントワーキングディレクトリから検索
const cwdPath = path.resolve(process.cwd(), filePath);
if (fs.existsSync(cwdPath)) {
console.error(`カレントディレクトリでファイルを発見: ${cwdPath}`);
return cwdPath;
}
// どこにも見つからなかった場合
throw new Error(`ファイル "${filePath}" が見つかりませんでした。絶対パスで指定してください。` +
` Windows形式(C:\\Users\\...)かWSL/Linux形式(/c/Users/...)で指定可能です。`);
} catch (error) {
throw error;
}
}
// JapaneseTextAnalyzerサーバークラス
class JapaneseTextAnalyzer {
private server: McpServer;
constructor() {
this.server = new McpServer({
name: 'JapaneseTextAnalyzer',
version: '1.0.0'
});
}
// テキストの文字数を計測する処理
private countTextCharsImpl(text: string, sourceName: string = 'テキスト') {
try {
// 改行とスペースを除外した文字数
const contentWithoutSpacesAndNewlines = text.replace(/[\s\n\r]/g, '');
const effectiveCharCount = contentWithoutSpacesAndNewlines.length;
return {
content: [{
type: 'text' as const,
text: `${sourceName}の文字数: ${effectiveCharCount}文字(改行・スペース除外)`
}]
};
} catch (error: any) {
return {
content: [{
type: 'text' as const,
text: `エラーが発生しました: ${error.message}`
}],
isError: true
};
}
}
// テキストの単語数を計測する処理
private async countTextWordsImpl(text: string, language: 'en' | 'ja' = 'en', sourceName: string = 'テキスト') {
try {
let wordCount = 0;
let resultText = '';
if (language === 'en') {
// 英語の場合、単語はスペースで区切られているためsplitで分割
const words = text.trim().split(/\s+/);
wordCount = words.length;
resultText = `${sourceName}の単語数: ${wordCount}単語 (英語モード)`;
} else if (language === 'ja') {
// 日本語の場合、kuromojiを使用して形態素解析
// 形態素解析器が利用可能かを確認
let tokenizer;
try {
tokenizer = await initializeTokenizer();
} catch (error) {
return {
content: [{
type: 'text' as const,
text: '形態素解析器の初期化に失敗しました。しばらく待ってから再試行してください。'
}],
isError: true
};
}
// 形態素解析を実行
const tokens = tokenizer.tokenize(text);
// 記号と空白以外のすべての単語をカウント(助詞や助動詞も含める)
const meaningfulTokens = tokens.filter((token: any) => {
// 記号と空白のみを除外
return !(token.pos === '記号' || token.pos === '空白');
});
wordCount = meaningfulTokens.length;
// 単語の詳細情報を出力
const tokenDetails = tokens.map((token: any) => {
return `【${token.surface_form}】 品詞: ${token.pos}, 品詞細分類: ${token.pos_detail_1}, 読み: ${token.reading}`;
}).join('\n');
resultText = `${sourceName}の単語数: ${wordCount}単語 (日本語モード、すべての品詞を含む)\n\n分析結果:\n${tokenDetails}\n\n有効な単語としてカウントしたもの:\n${meaningfulTokens.map((t: any) => t.surface_form).join(', ')}`;
}
return {
content: [{
type: 'text' as const,
text: resultText
}]
};
} catch (error: any) {
return {
content: [{
type: 'text' as const,
text: `エラーが発生しました: ${error.message}`
}],
isError: true
};
}
}
// テキストの形態素解析結果を返す関数を追加
private async analyzeTextImpl(text: string) {
try {
// 形態素解析器の初期化チェック
let tokenizer;
try {
tokenizer = await initializeTokenizer();
} catch (error) {
return {
content: [{
type: 'text' as const,
text: '形態素解析器の初期化に失敗しました。しばらく待ってから再試行してください。'
}],
isError: true
};
}
// テキストを文に分割(。で区切る)
const sentences = text.split(/[。.!?!?]/g).filter(s => s.trim().length > 0);
// 形態素解析を実行
const tokens = tokenizer.tokenize(text);
// 基本的な分析結果
const totalChars = text.replace(/[\s\n\r]/g, '').length;
const totalSentences = sentences.length;
const totalMorphemes = tokens.length;
// 品詞別のカウント
const posCounts: Record<string, number> = {};
const particleCounts: Record<string, number> = {};
let totalParticles = 0;
// 文字種別のカウント
const scriptCounts = {
hiragana: 0,
katakana: 0,
kanji: 0,
alphabet: 0,
digit: 0,
other: 0
};
// 単語の一意性確認用
const uniqueWords = new Set<string>();
let katakanaWords = 0;
let punctuationCount = 0;
const honorificExpressions = ['です', 'ます', 'でした', 'ました', 'ございます', 'いただく', 'なさる', 'れる', 'られる', 'どうぞ', 'お', 'ご'];
let honorificCount = 0;
// 各トークンを処理
tokens.forEach((token: any) => {
// 品詞カウント
posCounts[token.pos] = (posCounts[token.pos] || 0) + 1;
// 助詞カウント
if (token.pos === '助詞') {
particleCounts[token.surface_form] = (particleCounts[token.surface_form] || 0) + 1;
totalParticles++;
}
// 単語カウント
uniqueWords.add(token.basic_form);
// カタカナ語カウント
if (/^[\u30A0-\u30FF]+$/.test(token.surface_form)) {
katakanaWords++;
}
// 句読点カウント
if (token.pos === '記号' && (token.pos_detail_1 === '句点' || token.pos_detail_1 === '読点')) {
punctuationCount++;
}
// 敬語表現カウント
if (honorificExpressions.some(expr => token.surface_form.includes(expr) || token.basic_form.includes(expr))) {
honorificCount++;
}
});
// 文字種のカウント
for (const char of text) {
if (/[\u3040-\u309F]/.test(char)) {
scriptCounts.hiragana++;
} else if (/[\u30A0-\u30FF]/.test(char)) {
scriptCounts.katakana++;
} else if (/[\u4E00-\u9FAF]/.test(char)) {
scriptCounts.kanji++;
} else if (/[a-zA-Z]/.test(char)) {
scriptCounts.alphabet++;
} else if (/[0-90-9]/.test(char)) {
scriptCounts.digit++;
} else if (!/\s/.test(char)) {
scriptCounts.other++;
}
}
// 各指標の計算
const totalNonSpaceChars = Object.values(scriptCounts).reduce((a, b) => a + b, 0);
// features.ymlに基づく解析結果
const analysisResults = {
average_sentence_length: {
name: '平均文長',
value: totalSentences > 0 ? (totalChars / totalSentences).toFixed(2) : '0.00',
unit: '文字/文',
description: '一文の長さ。長すぎると読みにくくなる。'
},
average_morphemes_per_sentence: {
name: '文あたりの形態素数',
value: totalSentences > 0 ? (totalMorphemes / totalSentences).toFixed(2) : '0.00',
unit: '形態素/文',
description: '文の密度や構文の複雑さを表す。'
},
pos_ratio: {
name: '品詞の割合',
value: Object.entries(posCounts).map(([pos, count]) => {
return `${pos}: ${((count / totalMorphemes) * 100).toFixed(2)}%`;
}).join(', '),
unit: '%',
description: '名詞・動詞・形容詞などの使用バランスを分析。'
},
particle_ratio: {
name: '助詞の割合',
value: Object.entries(particleCounts)
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([particle, count]) => {
return `${particle}: ${((count / totalParticles) * 100).toFixed(2)}%`;
}).join(', '),
unit: '%',
description: '主語・目的語などの構造分析や文の流れを判断。'
},
script_type_ratio: {
name: '文字種の割合',
value: Object.entries(scriptCounts).map(([type, count]) => {
return `${type}: ${((count / totalNonSpaceChars) * 100).toFixed(2)}%`;
}).join(', '),
unit: '%',
description: 'ひらがな・カタカナ・漢字・英数字の構成比率。'
},
vocabulary_diversity: {
name: '語彙の多様性(タイプ/トークン比)',
value: ((uniqueWords.size / totalMorphemes) * 100).toFixed(2),
unit: '%',
description: '語彙の豊かさや表現力の指標。'
},
katakana_word_ratio: {
name: 'カタカナ語の割合',
value: ((katakanaWords / totalMorphemes) * 100).toFixed(2),
unit: '%',
description: '外来語や専門用語の多さ、カジュアルさを示す。'
},
honorific_frequency: {
name: '敬語の頻度',
value: totalSentences > 0 ? (honorificCount / totalSentences).toFixed(2) : '0.00',
unit: '回/文',
description: '丁寧・フォーマルさを示す。'
},
punctuation_per_sentence: {
name: '句読点の平均数',
value: totalSentences > 0 ? (punctuationCount / totalSentences).toFixed(2) : '0.00',
unit: '個/文',
description: '文の区切りや読みやすさに影響。'
}
};
// 結果をテキスト形式で整形
const resultText = `# テキスト分析結果
## 基本情報
- 総文字数: ${totalChars}文字
- 文の数: ${totalSentences}
- 総形態素数: ${totalMorphemes}
## 詳細分析
${Object.entries(analysisResults).map(([key, data]) => {
return `### ${data.name} (${data.unit})
- 値: ${data.value}
- 説明: ${data.description}`;
}).join('\n\n')}
`;
return {
content: [{
type: 'text' as const,
text: resultText
}]
};
} catch (error: any) {
return {
content: [{
type: 'text' as const,
text: `分析中にエラーが発生しました: ${error.message}`
}],
isError: true
};
}
}
// ツールをセットアップ
setupTools() {
// ファイルの文字数を計測
this.server.tool(
'count-chars',
'ファイルの文字数を計測します。絶対パスを指定してください(Windows形式 C:\\Users\\...、またはWSL/Linux形式 /c/Users/... のどちらも可)。スペースや改行を除いた実質的な文字数をカウントします。',
{
filePath: z.string().describe('文字数をカウントするファイルのパス(Windows形式かWSL/Linux形式の絶対パスを推奨)')
},
async ({ filePath }) => {
try {
// ファイルパスを解決
const resolvedPath = resolveFilePath(filePath);
const fileContent = fs.readFileSync(resolvedPath, 'utf8');
return this.countTextCharsImpl(fileContent, `ファイル '${resolvedPath}'`);
} catch (error: any) {
return {
content: [{
type: 'text' as const,
text: `ファイル読み込みエラー: ${error.message}`
}],
isError: true
};
}
}
);
// ファイルの単語数を計測
this.server.tool(
'count-words',
'ファイルの単語数を計測します。絶対パスを指定してください(Windows形式 C:\\Users\\...、またはWSL/Linux形式 /c/Users/... のどちらも可)。英語ではスペースで区切られた単語をカウントし、日本語では形態素解析を使用します。',
{
filePath: z.string().describe('単語数をカウントするファイルのパス(Windows形式かWSL/Linux形式の絶対パスを推奨)'),
language: z.enum(['en', 'ja']).default('en').describe('ファイルの言語 (en: 英語, ja: 日本語)')
},
async ({ filePath, language }) => {
try {
// ファイルパスを解決
const resolvedPath = resolveFilePath(filePath);
const fileContent = fs.readFileSync(resolvedPath, 'utf8');
return await this.countTextWordsImpl(fileContent, language, `ファイル '${resolvedPath}'`);
} catch (error: any) {
return {
content: [{
type: 'text' as const,
text: `ファイル読み込みエラー: ${error.message}`
}],
isError: true
};
}
}
);
// テキストの文字数を計測
this.server.tool(
'count-clipboard-chars',
'テキストの文字数を計測します。スペースや改行を除いた実質的な文字数をカウントします。',
{ text: z.string().describe('文字数をカウントするテキスト') },
async ({ text }) => this.countTextCharsImpl(text)
);
// テキストの単語数を計測
this.server.tool(
'count-clipboard-words',
'テキストの単語数を計測します。英語ではスペースで区切られた単語をカウントし、日本語では形態素解析を使用します。',
{
text: z.string().describe('単語数をカウントするテキスト'),
language: z.enum(['en', 'ja']).default('en').describe('テキストの言語 (en: 英語, ja: 日本語)')
},
async ({ text, language }) => await this.countTextWordsImpl(text, language)
);
// テキストの詳細分析
this.server.tool(
'analyze-text',
'テキストの詳細な形態素解析と言語的特徴の分析を行います。文の複雑さ、品詞の割合、語彙の多様性などを解析します。',
{
text: z.string().describe('分析するテキスト')
},
async ({ text }) => await this.analyzeTextImpl(text)
);
// ファイルの詳細分析
this.server.tool(
'analyze-file',
'ファイルの詳細な形態素解析と言語的特徴の分析を行います。文の複雑さ、品詞の割合、語彙の多様性などを解析します。',
{
filePath: z.string().describe('分析するファイルのパス(Windows形式かWSL/Linux形式の絶対パスを推奨)')
},
async ({ filePath }) => {
try {
// ファイルパスを解決
const resolvedPath = resolveFilePath(filePath);
const fileContent = fs.readFileSync(resolvedPath, 'utf8');
return await this.analyzeTextImpl(fileContent);
} catch (error: any) {
return {
content: [{
type: 'text' as const,
text: `ファイル読み込みエラー: ${error.message}`
}],
isError: true
};
}
}
);
}
// サーバーを起動
async start() {
try {
// ツールをセットアップ
this.setupTools();
// サーバーを起動
const transport = new StdioServerTransport();
await this.server.connect(transport);
console.error('サーバーが起動しました。標準入出力からの要求を待機しています...');
} catch (error) {
console.error(`サーバーの起動中にエラーが発生しました: ${error.message || error}`);
throw error;
}
}
}
// メイン関数
async function main() {
try {
// サーバー起動前に形態素解析器を初期化
console.error('サーバー起動前に形態素解析器の初期化を開始します...');
try {
await initializeTokenizer();
console.error('形態素解析器の初期化が完了しました');
} catch (err) {
console.error(`形態素解析器の初期化中にエラーが発生しましたが、サーバーは起動を続行します: ${err.message || err}`);
}
// サーバーインスタンスを作成
const server = new JapaneseTextAnalyzer();
// サーバーを起動
await server.start();
} catch (error) {
console.error(`サーバーの起動中にエラーが発生しました: ${error.message || error}`);
process.exit(1);
}
}
main().catch(error => {
console.error('エラーが発生しました:', error);
process.exit(1);
});