PWAで完全オフラインの「日記→自己肯定メッセージ変換アプリ」を作った話
はじめに
日記を書いても、どうしてもネガティブな内容になってしまう...そんな経験はありませんか?
今回、日記をポジティブな自己肯定メッセージに変換するPWAアプリ「ポジ変換」を開発しました。完全オフライン動作で、個人情報も一切外部送信しないプライバシー重視の設計です。
この記事では、その開発過程で得た技術的な学びを共有します。
完成したアプリの概要
デモ
入力:
今日は会議でうまく話せなかった。資料も間に合わなかった。
出力(100文字以内):
会議を通じて、挑戦の機会があった。課題も見つかり、次に活かせる。一歩ずつ成長している!
主な機能
- 📝 日記入力(500文字まで)
- 🔄 ネガティブ→ポジティブ変換
- 🔒 個人情報の自動マスキング
- 📱 完全オフライン動作(PWA)
- 💯 100文字以内の要約生成
技術スタック
- Vanilla JavaScript(フレームワークなし)
- Service Worker(オフライン対応)
- PWA(Progressive Web App)
- GitHub Pages(ホスティング)
技術的なチャレンジ
1. 形態素解析なしで自然な日本語を生成する
当初はTinySegmenterなどの形態素解析ライブラリを使う予定でしたが、以下の理由で断念:
- ライブラリサイズが大きい
- ネットワーク経由の取得が必要
- 完全オフラインとの相性が悪い
解決策: パターンマッチング + テンプレート生成
正規表現と辞書ベースで要素を抽出し、自然な文章を組み立てる方式を採用しました。
// 要素の定義
const conversionPatterns = {
// ネガティブ表現とその変換
negatives: [
{
regex: /できなかった/,
original: 'できなかった',
converted: '挑戦できた',
context: '挑戦'
},
{
regex: /失敗(した|して)/,
original: '失敗',
converted: '挑戦した',
context: '挑戦'
},
// ... 他15パターン
],
// 行動パターン
actions: [
{
regex: /(会議|ミーティング)/,
value: '会議',
positive: '議論'
},
// ... 他8パターン
],
// ポジティブ要素
positives: [
{ regex: /(嬉しい|嬉しかった)/, value: '嬉しい' },
// ... 他10パターン
]
};
文章生成ロジック
抽出した要素を組み合わせて、文脈に沿った自然な文章を生成:
function generateNaturalText(analysis) {
const sentences = [];
const hasNegative = analysis.negatives.length > 0;
const hasPositive = analysis.positives.length > 0;
// パターン1: ネガティブがある場合
if (hasNegative) {
const action = analysis.actions[0]?.positive || '様々なこと';
const context = analysis.negatives[0].context;
sentences.push(`${action}を通じて、${context}の機会があった`);
if (hasPositive) {
sentences.push(`${analysis.positives[0]}瞬間もあった`);
} else {
sentences.push(`課題も見つかり、次に活かせる`);
}
sentences.push(`一歩ずつ成長している!`);
}
// パターン2, 3, 4... (省略)
// 100文字以内に調整
let result = sentences.join('。') + '。';
if (result.length > 100) {
// 最後の文を削って調整
const lastPeriod = result.lastIndexOf('。', 95);
if (lastPeriod > 50) {
result = result.substring(0, lastPeriod + 1);
}
}
return result;
}
結果:
- 形態素解析なしで自然な日本語生成に成功
- ファイルサイズ: わずか10KB
- 処理速度: <100ms
2. 個人情報の自動マスキング
SNS投稿を想定し、個人情報を自動検出・マスキングする機能を実装しました。
検出対象
-
正規表現で検出
- メールアドレス
- 電話番号
- URL
- 郵便番号
-
辞書ベースで検出
- 人名(姓+敬称パターン)
- 地名(都道府県・市区町村)
// 人名検出
const surnames = ['佐藤', '鈴木', '高橋', /* ... */];
const pattern = new RegExp(`(${surnames.join('|')})[一-龠ぁ-んァ-ヶー]+(さん|くん|ちゃん|様)`, 'g');
// 地名検出(長い地名から優先)
const places = ['大村市水主町', '大村市', '長崎県', /* ... */];
const sortedPlaces = places.sort((a, b) => b.length - a.length);
マスキング処理
function maskPersonalInfo(text) {
const detections = [];
// 各パターンで検出
detectEmails(text).forEach(d => detections.push(d));
detectPhones(text).forEach(d => detections.push(d));
detectNames(text).forEach(d => detections.push(d));
detectPlaces(text).forEach(d => detections.push(d));
// インデックスの降順でソート(後ろから置換)
detections.sort((a, b) => b.index - a.index);
// マスキング実行
let masked = text;
detections.forEach(det => {
masked = masked.substring(0, det.index) +
det.replacement +
masked.substring(det.index + det.text.length);
});
return masked;
}
ポイント:
- 後ろから置換することでインデックスのズレを防止
- 長い地名から検出することで重複を回避
3. 個人情報の自動マスキング
SNS投稿を想定し、個人情報を自動検出・マスキングする機能を実装しました。
検出対象
-
正規表現で検出
- メールアドレス
- 電話番号
- URL
- 郵便番号
-
辞書ベースで検出
- 人名(姓+敬称パターン)
- 地名(都道府県・市区町村)
// 人名検出
const surnames = ['佐藤', '鈴木', '高橋', /* ... */];
const pattern = new RegExp(`(${surnames.join('|')})[一-龠ぁ-んァ-ヶー]+(さん|くん|ちゃん|様)`, 'g');
// 地名検出(長い地名から優先)
const places = ['大村市水主町', '大村市', '長崎県', /* ... */];
const sortedPlaces = places.sort((a, b) => b.length - a.length);
マスキング処理
function maskPersonalInfo(text) {
const detections = [];
// 各パターンで検出
detectEmails(text).forEach(d => detections.push(d));
detectPhones(text).forEach(d => detections.push(d));
detectNames(text).forEach(d => detections.push(d));
detectPlaces(text).forEach(d => detections.push(d));
// インデックスの降順でソート(後ろから置換)
detections.sort((a, b) => b.index - a.index);
// マスキング実行
let masked = text;
detections.forEach(det => {
masked = masked.substring(0, det.index) +
det.replacement +
masked.substring(det.index + det.text.length);
});
return masked;
}
ポイント:
- 後ろから置換することでインデックスのズレを防止
- 長い地名から検出することで重複を回避
課題:
- 地名辞書が長崎県周辺に偏っている(ローカライズの問題)
- 全国対応するには都道府県別の詳細地名データが必要
- 現在は主要都市のみカバー
4. 完全オフラインPWAの実装
Service Worker
const CACHE_NAME = 'poji-henkan-v1';
const urlsToCache = [
'/',
'/index.html',
'/manifest.json',
'/privacy-detector.js',
'/conversion-logic.js'
];
// キャッシュ優先戦略
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request)
.then(response => response || fetch(event.request))
);
});
manifest.json
{
"name": "ポジ変換",
"short_name": "ポジ変換",
"start_url": "/",
"display": "standalone",
"background_color": "#FFF9F0",
"theme_color": "#FFB84D",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
}
]
}
ファイル構成
poji-henkan/
├── index.html # メインアプリ
├── manifest.json # PWA設定
├── service-worker.js # オフライン対応
├── privacy-detector.js # 個人情報検出ロジック
└── conversion-logic.js # 変換ロジック
総ファイルサイズ: 約50KB (画像除く)
パフォーマンス
- 初回ロード: <1秒
- 変換処理: <100ms
- オフライン起動: <500ms
- Lighthouse スコア: Performance 95+
苦労した点
1. 自然な日本語生成
単純なテンプレート置換では不自然な文章になってしまうため、文脈を考慮したパターン生成ロジックの設計に時間がかかりました。
改善プロセス:
- v1: 「会議や資料に取り組めた」(不自然)
- v2: 「会議を通じて、経験を積めた」(改善)
- v3: 「会議を通じて、挑戦の機会があった」(採用)
2. 100文字の制約
100文字以内に収めるため、文章の優先順位付けとトリミングロジックを実装:
// 文章を結合し、100文字を超えたら調整
let result = '';
for (const sentence of sentences) {
const newText = result + sentence + '。';
if (newText.length > 100) {
if (result.length < 90) {
result += '成長できている!';
}
break;
}
result = newText;
}
3. 個人情報検出の精度
地名の重複検出(例: 「大村市」と「大村市水主町」)を防ぐため、長い地名から検出する必要がありました。
今後の改善予定
1. 変換品質の向上
現状の課題:
- パターンマッチングベースのため、変換パターンに依存
- 文脈理解が限定的
- 地名検出がローカライズされている(長崎県周辺に偏り)
改善案①: パターンの拡充
- 現在15パターン → 100パターンへ
- 地名辞書を全国対応(都道府県別の詳細データ)
- ユーザーフィードバックを元に改善
改善案②: 小規模言語モデル(SLM)の検討
案外良い感じの文章が生成できたことは学びでしたが、より自然な変換には機械学習が有効かもしれません。
検討中の選択肢:
- Phi-3-mini (3.8B): 2.3GB、ブラウザでは厳しい
- TinyLlama (1.1B): 600MB、可能性あり
- DistilBERT: 軽量だが要約向き
- WebLLM: ブラウザで推論可能だが学習は不可
実現の課題:
- 学習データの準備(ネガティブ→ポジティブのペア 5,000-20,000件)
- モバイルでの推論速度(目標: <3秒)
- ファイルサイズの肥大化(現在50KB → 数百MB?)
- オフライン動作との両立
現実的な落としどころ:
- サーバーサイドでSLMを使った「プレミアム機能」の提供?
- 基本機能は現在のパターンマッチングを維持
- ハイブリッド方式の検討
2. ユーザー学習機能
- よく使う表現を学習
- IndexedDBで保存
3. SNSシェア機能
- Twitter、Instagram連携
- OGP画像の自動生成
4. 多言語対応
- 英語版の開発
まとめ
形態素解析ライブラリを使わずに、パターンマッチングとテンプレート生成で自然な日本語変換を実現できました。
学び:
- 軽量さを優先すれば、複雑なライブラリは不要
- 正規表現と辞書で70-80%の精度は達成可能
- 案外良い感じの文章が生成できた
- PWAは完全オフラインでも十分実用的
課題:
- より高品質な変換には事前学習データが必要
- 地名辞書のローカライズ問題(全国対応には膨大なデータが必要)
- SLMの導入は技術的に可能だが、トレードオフが大きい
完全にオフラインで動作するため、プライバシーを重視するアプリに最適です。
将来的にはSLMの導入も検討していますが、まずは現在のパターンマッチング方式で十分な価値を提供できると考えています。
リポジトリ・デモ
- デモサイト:
- GitHubリポジトリ:
ぜひ実際に使ってみてください!
参考にした技術記事
おわりに
このアプリは、まめここ日記アプリのスピンオフとして開発しました。SEO対策も兼ねて別ドメインで運用しています。
「日記を書いても自己否定的になってしまう」という悩みを持つ方の助けになれば幸いです。