0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

PWAで完全オフラインの「日記→自己肯定メッセージ変換アプリ」を作った話

Posted at

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投稿を想定し、個人情報を自動検出・マスキングする機能を実装しました。

検出対象

  1. 正規表現で検出

    • メールアドレス
    • 電話番号
    • URL
    • 郵便番号
  2. 辞書ベースで検出

    • 人名(姓+敬称パターン)
    • 地名(都道府県・市区町村)
// 人名検出
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投稿を想定し、個人情報を自動検出・マスキングする機能を実装しました。

検出対象

  1. 正規表現で検出

    • メールアドレス
    • 電話番号
    • URL
    • 郵便番号
  2. 辞書ベースで検出

    • 人名(姓+敬称パターン)
    • 地名(都道府県・市区町村)
// 人名検出
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対策も兼ねて別ドメインで運用しています。

「日記を書いても自己否定的になってしまう」という悩みを持つ方の助けになれば幸いです。

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?