🔄

PHP 5.2からLaravel 11への大規模リニューアル #3 データ移行戦略と文字コード問題への対処

に公開

はじめに

前回の記事では、新システムのアーキテクチャ設計と技術選定について解説しました。

※前回の記事リンク:https://zenn.dev/abg/articles/041e12df4015b0

今回は、プロジェクトで最も困難だったデータ移行について詳しくご紹介します。
単純なデータのコピーではなく、データ構造の根本的な見直しが必要でした。
特にEUC-JPからUTF-8mb4への文字コード変換、パーソナリティのデータベース化、イベント管理の戦略的判断など、データ設計の再考が移行成功の鍵となりました。

データ移行の全体戦略

移行対象の規模

総レコード数: 約14,500件
- ユーザーデータ: 859件
- 番組情報: 1,666件
- ニュース記事: 116件
- ゲスト情報: 11,191件
- 放送局: 約200件
- 番組スケジュール: 約500件

段階的移行アプローチ

ユーザーデータをあえて移行しなかった理由

実は今回のリニューアルで、ユーザーデータは意図的に移行しないという大胆な判断をしました:

  1. セキュリティの根本的な改善

    • MD5ハッシュのパスワードをそのまま移行するのはセキュリティリスクが高い
    • 新システムでbcryptを採用するため、パスワードの再設定が必要
  2. 認証方式の現代化

    • ソーシャルログイン(Google/X)の実装を優先
    • アイパス(ID/Password)方式だけでは現代のUXに適合しない
  3. アクティブユーザーの再定義

    • 859件のユーザーデータのうち、実際にアクティブなユーザーは限定的
    • 新規登録により、真にアクティブなユーザーベースを構築

この判断により、技術的負債を完全にリセットし、より安全で使いやすい認証システムを構築できました。

文字コード問題の深刻さ

EUC-JPの制約

レガシーシステムではEUC-JPを使用していたため、以下の問題がありました:

// 旧システムでの文字化けの例
$name = "髙橋"; // 「髙」が正しく扱えない
$emoji = "😊"; // 絵文字は使用不可
$special = "Ⅲ"; // ローマ数字が文字化け

文字化けパターンの分析

移行前に、実際のデータから文字化けパターンを抽出:

// app/Console/Commands/AnalyzeEncodingIssues.php
class AnalyzeEncodingIssues extends Command
{
    public function handle()
    {
        $issues = [];
        
        // 旧データベースから読み込み
        $oldDb = DB::connection('old_mysql');
        $records = $oldDb->select('SELECT * FROM programs');
        
        foreach ($records as $record) {
            // 複数のエンコーディングで変換を試みる
            $conversions = [
                'EUC-JP' => mb_convert_encoding($record->title, 'UTF-8', 'EUC-JP'),
                'SJIS' => mb_convert_encoding($record->title, 'UTF-8', 'SJIS'),
                'JIS' => mb_convert_encoding($record->title, 'UTF-8', 'JIS'),
            ];
            
            // 文字化けの検出
            foreach ($conversions as $encoding => $converted) {
                if ($this->containsMojibake($converted)) {
                    $issues[] = [
                        'original' => $record->title,
                        'encoding' => $encoding,
                        'converted' => $converted,
                        'pattern' => $this->detectMojibakePattern($converted),
                    ];
                }
            }
        }
        
        $this->generateReport($issues);
    }
    
    private function containsMojibake($str): bool
    {
        // 文字化けの典型的なパターン
        $patterns = [
            '/[?]{3,}/',           // ???の連続
            '/\x{FFFD}/u',         // Unicode置換文字
            '/[☐⬜□]/u',          // 豆腐文字
        ];
        
        foreach ($patterns as $pattern) {
            if (preg_match($pattern, $str)) {
                return true;
            }
        }
        
        return false;
    }
}

パーソナリティ名の表記揺れという最大の難関

文字コード問題と並んで最も苦戦したのが、パーソナリティ名の表記揺れ問題でした。

実際に直面した表記揺れの例

旧システムでは、ゲスト情報が自由記述のテキストフィールドで管理されていました。その結果、同じパーソナリティでも以下のような多様な表記が存在:

// データベースから抽出した実際の表記例
$variations = [
    // ユニット表記のバリエーション
    'やまとなでしこ(田村ゆかり、堀江由衣)',
    'やまとなでしこ(堀江由衣・田村ゆかり)',
    'やまとなでしこ(田村ゆかり/堀江由衣)',
    
    // 改名・表記変更パターン
    '石田燿子',
    '石田よう子',
    '栗林みな実',
    'Minami',
    'minami(栗林みな実)',
    
    // 区切り文字の違い
    'Aqours(高槻かなこ、小林愛香)',
    'Aqours(高槻かなこ/小林愛香)',
    'Aqours:高槻かなこ&小林愛香',
];

ユニット所属の複雑性

特に悩ましかったのが、ユニットメンバーの部分参加パターンです:

// 実際のデータ例とその解釈の難しさ
$complexCases = [
    [
        'original' => 'Aqours(斉藤朱夏、小宮有紗)',
        'interpretation' => [
            '案1: Aqoursから2名のみ参加',
            '案2: Aqours代表として2名',
            '案3: たまたま2名の名前を記載',
        ]
    ],
    [
        'original' => 'Aqours',
        'interpretation' => [
            '案1: 9名全員が参加',
            '案2: メンバー不明',
            '案3: ユニットとしての参加',
        ]
    ],
    [
        'original' => 'μ\'s(新田恵海他)',
        'interpretation' => [
            '案1: 新田恵海を中心に複数名',
            '案2: 全員参加だが代表名のみ記載',
        ]
    ],
];

解決策:個人とユニットの柔軟な階層構造

この問題を解決するため、パーソナリティを以下の3タイプに分類:

// app/Enums/PersonalityType.php
enum PersonalityType: string
{
    case INDIVIDUAL = 'individual';  // 個人
    case UNIT = 'unit';             // ユニット
    case SPECIAL = 'special';       // 特殊(番組キャラクター等)
}

// データベース設計
Schema::create('personalities', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->string('name_kana')->nullable();
    $table->enum('type', ['individual', 'unit', 'special']);
    $table->json('aliases')->nullable(); // 別名・旧名を保存
    $table->timestamps();
});

Schema::create('personality_units', function (Blueprint $table) {
    $table->id();
    $table->foreignId('unit_id')->constrained('personalities');
    $table->foreignId('member_id')->constrained('personalities');
    $table->date('joined_at')->nullable();
    $table->date('left_at')->nullable();
    $table->timestamps();
});

ユニットの入れ子構造への対応

さらに複雑なケースとして、ユニットに所属するユニットにも対応しました:

// 実際の例:Aqours内のサブユニット構造
$unitHierarchy = [
    'Aqours' => [
        'members' => ['伊波杏樹', '逢田梨香子', '諏訪ななか', ...],
        'subunits' => [
            'CYaRon!' => ['伊波杏樹', '斉藤朱夏', '降幡愛'],
            'AZALEA' => ['諏訪ななか', '小宮有紗', '高槻かなこ'],
            'Guilty Kiss' => ['逢田梨香子', '小林愛香', '鈴木愛奈'],
        ]
    ],
];

// キャラクター名での登録パターン(一人ユニットとして実装)
$characterAsUnit = [
    '高海千歌(CV.伊波杏樹)' => [
        'type' => 'unit',  // 一人ユニットとして登録
        'members' => ['伊波杏樹'],
    ],
    '津島善子' => [
        'type' => 'unit',
        'members' => ['小林愛香'],
    ],
];

// personality_unitsテーブルの工夫
// member_idには個人だけでなくユニットも設定可能
$examples = [
    ['unit_id' => 'Aqours', 'member_id' => 'Guilty Kiss'],  // ユニット→ユニット
    ['unit_id' => 'Guilty Kiss', 'member_id' => '小林愛香'],  // ユニット→個人(声優)
    ['unit_id' => '津島善子', 'member_id' => '小林愛香'],  // キャラクター(一人ユニット)→声優
];

この複雑な構造により、声優名での登録とキャラクター名での登録の両方に対応。キャラクター名は「一人ユニット」として扱うことで、データ構造の一貫性を保ちました。

この設計により、「Guilty Kiss」が番組にゲスト出演した場合でも、Aqoursの一部として、またはGuilty Kiss単独として、柔軟に記録できるようになりました。

名寄せアルゴリズムの実装

Claude Codeと協働して、表記揺れを吸収する名寄せアルゴリズムを開発:

// app/Services/PersonalityMatcher.php
class PersonalityMatcher
{
    public function findOrCreate(string $rawName): array
    {
        $parsed = $this->parseGuestString($rawName);
        $results = [];
        
        // ユニット名が含まれる場合
        if ($parsed['unit']) {
            $unit = $this->findUnitByVariations($parsed['unit']);
            if ($unit) {
                $results[] = $unit;
            }
        }
        
        // 個人名の処理
        foreach ($parsed['individuals'] as $name) {
            $personality = $this->findByNameVariations($name);
            if (!$personality) {
                // 新規作成(手動確認フラグ付き)
                $personality = Personality::create([
                    'name' => $name,
                    'type' => 'individual',
                    'needs_review' => true,
                ]);
            }
            $results[] = $personality;
        }
        
        return $results;
    }
    
    private function parseGuestString(string $str): array
    {
        // 括弧内の処理
        if (preg_match('/^(.+?)[(\(](.+?)[)\)]/', $str, $matches)) {
            $unit = trim($matches[1]);
            $members = preg_split('/[、・,\/&&]/', $matches[2]);
            
            return [
                'unit' => $unit,
                'individuals' => array_map('trim', $members),
            ];
        }
        
        // 単独名の場合
        return [
            'unit' => null,
            'individuals' => [$str],
        ];
    }
}

移行後の手動確認プロセス

完全な自動化は不可能だったため、以下の確認プロセスを実装:

  1. 自動マッチング率: 約70%が自動で正しく分類
  2. 要確認フラグ: 30%に手動確認フラグを設定
  3. 管理画面での統合機能: 重複や誤分類を手動で修正

AIでは解決できない同姓同名問題

さらに困難だったのが、同一表記の別人物の判別です:

// 実際に直面した同姓同名の例
$ambiguousCases = [
    '前田愛' => [
        '声優の前田愛(AiM)',     // アニメラジオ番組に出演
        '女優の前田愛(まえだあい)', // ドラマ・映画関連番組に出演
        // 両方ともラジオ番組に出演する可能性があり判別困難
    ],
    '田中理恵' => [
        '声優の田中理恵',          // アニメ・ゲーム番組に多数出演
        // 体操選手の田中理恵はアニラジには出演しないため、実質声優のみ登録
    ],
];

// 番組傾向による判別を試みたが...
class PersonalityDisambiguator
{
    public function disambiguate(string $name, Program $program): ?Personality
    {
        // 番組カテゴリからの推測
        if ($program->category === 'anime') {
            // アニメ番組なら声優の可能性が高い
            return $this->findByNameAndType($name, 'voice_actor');
        }
        
        // しかし、完全な判別は不可能
        // 声優がバラエティ番組に出演することもあるため
        return null; // 手動確認が必要
    }
}

この問題は技術的に完全解決は不可能で、現在も過去のデータを見ながら継続的に修正作業を行っています。データ移行は「完了」ではなく、継続的な改善プロセスであることを痛感しました。

この経験から学んだのは、完璧を求めすぎず、7割の自動化と3割の手動作業の組み合わせが現実的ということでした。

イベントレポートの移行戦略

独自ファイルからデータベースへの移行

旧システムでは、イベントレポートは独自のHTMLファイルで管理していました:

// 旧システムのイベントレポート構造
events/
├── 2020/
│   ├── 0312_animejapan/
│   │   ├── index.html      // 親記事
│   │   ├── stage1.html     // 子記事(ステージイベント)
│   │   ├── stage2.html     // 子記事(別ステージ)
│   │   └── images/         // 写真フォルダ
│   └── 1225_comiket/
│       └── ...

データベース化の設計判断

イベントレポートの移行で直面した課題と解決策:

// 新システムでの階層構造対応
Schema::create('events', function (Blueprint $table) {
    $table->id();
    $table->string('title');
    $table->text('content');
    $table->foreignId('parent_id')->nullable()
          ->constrained('events')->onDelete('cascade');  // 親子関係
    $table->date('event_date');
    $table->timestamps();
});

Schema::create('event_photos', function (Blueprint $table) {
    $table->id();
    $table->foreignId('event_id')->constrained();
    $table->string('file_path');
    $table->string('caption')->nullable();
    $table->integer('display_order');
    $table->timestamps();
});

必須要件だった画像掲載機能親子階層構造を実現しました。

イベント情報の管理範囲の決断

重要な設計判断として、すべてのイベント情報を管理しないことを選択:

// 管理対象の判定基準
class EventScope
{
    public function shouldImport(array $eventData): bool
    {
        // アニラジが主体となるイベントのみ
        if ($eventData['type'] === 'radio_event') {
            return true;
        }
        
        // アニメイベントでもラジオブースがある場合のみ
        if ($eventData['has_radio_booth']) {
            return true;
        }
        
        // それ以外は管理対象外
        return false;
    }
}

この決断により、管理の複雑化を避け、アニラジに特化したサイトとしての独自性を維持しました。

Eventernoteとの連携による解決

管理対象外のイベント情報については、Eventernoteとの連携で補完:

// app/Services/EventernoteService.php
class EventernoteService
{
    public function linkActor(int $actorId, int $personalityId): void
    {
        // EventernoteのActor IDとパーソナリティIDを紐付け
        DB::table('eventernote_actor_links')->insert([
            'eventernote_actor_id' => $actorId,
            'personality_id' => $personalityId,
            'created_at' => now(),
        ]);
    }
    
    public function getEventInfo(string $eventCode): ?array
    {
        // EventernoteのAPIから情報取得(読み取り専用)
        return Http::get("https://api.eventernote.com/events/{$eventCode}")
                   ->json();
    }
}

// 実際の連携例
// - Aniradi → Eventernote: Actor情報の参照、イベント詳細の取得
// - Eventernote → Aniradi: 連携なし(一方向の参照のみ)

この連携により、イベント情報の網羅性を保ちつつ、管理コストを最小限に抑えることができました。

Artisanコマンドによる移行自動化

データ移行の自動化にあたり、Claude Codeと協働して65個のArtisanコマンドを生成しました。しかし、AIが生成したコードをそのまま使用するのではなく、段階的な検証が重要でした。

AI駆動開発の注意点

AIとの協働開発において学んだ重要なポイント:

  1. 提案の検証を必ず行う

    • AIの提案するコードは論理的に正しく見えても、実データで問題が発生することがある
    • 特に文字コード変換では、実際のデータでテストが必須
  2. 段階的な実装とテスト

    • 一度に全データを移行せず、少量のサンプルデータで検証
    • 問題が発生した場合のロールバック戦略を準備
  3. エッジケースの明示的な指示

    • AIは一般的なケースは得意だが、業務特有の仕様は明示的に伝える必要がある
    • 例:30時間制、和暦変換、特殊文字の扱い

統合移行コマンドの実装

// app/Console/Commands/MigrateAllData.php
class MigrateAllData extends Command
{
    protected $signature = 'aniradi:migrate-all {--fresh : 既存データを削除}';
    
    public function handle()
    {
        $this->info('Aniradi Network データ移行開始');
        
        DB::beginTransaction();
        
        try {
            // 移行順序は依存関係を考慮
            $this->call('aniradi:migrate-stations');
            $this->call('aniradi:migrate-personalities');
            $this->call('aniradi:migrate-programs');
            $this->call('aniradi:migrate-schedules');
            $this->call('aniradi:migrate-users');
            $this->call('aniradi:migrate-news');
            $this->call('aniradi:migrate-guests');
            $this->call('aniradi:migrate-events');
            
            DB::commit();
            $this->info('全データ移行完了!');
            
            // レポート生成
            $this->call('aniradi:generate-migration-report');
            
        } catch (\Exception $e) {
            DB::rollback();
            $this->error('移行エラー: ' . $e->getMessage());
            return 1;
        }
    }
}

文字コード変換の実装

Claude Codeとの対話で、文字化けパターンの収集と対策を効率化できました。ただし、実際のデータベースからサンプルを抽出し、変換結果を目視確認することが重要でした。

// app/Services/EncodingConverter.php
class EncodingConverter
{
    private array $conversionMap = [];
    
    public function __construct()
    {
        // よくある文字化けの修正マップ
        $this->conversionMap = [
            '高' => '髙',  // はしごだか
            'Ⅲ' => 'III', // ローマ数字
            '㈱' => '(株)', // 株式会社記号
            '①' => '(1)',  // 丸数字
        ];
    }
    
    public function convert(string $text): string
    {
        // 基本変換
        $converted = mb_convert_encoding($text, 'UTF-8', 'EUC-JP');
        
        // NULL文字の除去
        $converted = str_replace("\0", '', $converted);
        
        // 制御文字の除去
        $converted = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $converted);
        
        // 既知の文字化けパターンを修正
        foreach ($this->conversionMap as $from => $to) {
            $converted = str_replace($from, $to, $converted);
        }
        
        // 半角カナを全角に変換
        $converted = mb_convert_kana($converted, 'KV', 'UTF-8');
        
        return $converted;
    }
    
    public function detectAndConvert($text)
    {
        // 文字エンコーディングを自動検出
        $encoding = mb_detect_encoding($text, ['EUC-JP', 'SJIS', 'JIS', 'UTF-8'], true);
        
        if (!$encoding) {
            // 検出失敗時のフォールバック
            $this->logEncodingError($text);
            return $this->fallbackConvert($text);
        }
        
        if ($encoding === 'UTF-8') {
            return $text;
        }
        
        return $this->convert($text);
    }
}

特殊なデータ移行ケース

HTMLコンテンツの移行

ニュース記事にはHTMLタグが混在していました:

// app/Console/Commands/MigrateNews.php
class MigrateNews extends Command
{
    public function migrateNewsContent($oldContent)
    {
        // HTMLエンティティのデコード
        $content = html_entity_decode($oldContent, ENT_QUOTES, 'UTF-8');
        
        // 旧システムの独自タグを変換
        $content = str_replace('<font color="red">', '<span class="text-danger">', $content);
        $content = str_replace('</font>', '</span>', $content);
        
        // 絶対パスを相対パスに変換
        $content = str_replace('http://old.aniradi.com/', '/', $content);
        
        // XSS対策のためのサニタイズ
        $config = HTMLPurifier_Config::createDefault();
        $purifier = new HTMLPurifier($config);
        
        return $purifier->purify($content);
    }
}

日付データの正規化

// 様々な形式の日付を統一
class DateNormalizer
{
    public function normalize($dateString)
    {
        // 和暦の変換
        if (preg_match('/平成(\d+)年(\d+)月(\d+)日/', $dateString, $matches)) {
            $year = 1988 + (int)$matches[1];
            return sprintf('%04d-%02d-%02d', $year, $matches[2], $matches[3]);
        }
        
        // 令和の変換
        if (preg_match('/令和(\d+)年(\d+)月(\d+)日/', $dateString, $matches)) {
            $year = 2018 + (int)$matches[1];
            return sprintf('%04d-%02d-%02d', $year, $matches[2], $matches[3]);
        }
        
        // その他の形式
        $formats = [
            'Y/m/d',
            'Y-m-d',
            'Y年m月d日',
            'd/m/Y',
            'm/d/Y',
        ];
        
        foreach ($formats as $format) {
            try {
                $date = Carbon::createFromFormat($format, $dateString);
                return $date->format('Y-m-d');
            } catch (\Exception $e) {
                continue;
            }
        }
        
        // パースできない場合
        throw new \Exception("日付形式を認識できません: {$dateString}");
    }
}

パーソナリティ名の名寄せ

同一人物が異なる表記で登録されている問題:

// app/Services/PersonalityMerger.php
class PersonalityMerger
{
    public function findDuplicates()
    {
        $personalities = Personality::all();
        $duplicates = [];
        
        foreach ($personalities as $p1) {
            foreach ($personalities as $p2) {
                if ($p1->id >= $p2->id) continue;
                
                // 表記揺れを考慮した比較
                if ($this->isSamePerson($p1, $p2)) {
                    $duplicates[] = [$p1, $p2];
                }
            }
        }
        
        return $duplicates;
    }
    
    private function isSamePerson($p1, $p2): bool
    {
        // 完全一致
        if ($p1->name === $p2->name) {
            return true;
        }
        
        // スペースの違いを無視
        $name1 = str_replace([' ', ' '], '', $p1->name);
        $name2 = str_replace([' ', ' '], '', $p2->name);
        
        if ($name1 === $name2) {
            return true;
        }
        
        // ひらがな/カタカナの違いを無視
        $kana1 = mb_convert_kana($name1, 'c', 'UTF-8');
        $kana2 = mb_convert_kana($name2, 'c', 'UTF-8');
        
        if ($kana1 === $kana2) {
            return true;
        }
        
        // レーベンシュタイン距離で類似度チェック
        $distance = levenshtein($name1, $name2);
        $similarity = 1 - ($distance / max(strlen($name1), strlen($name2)));
        
        return $similarity > 0.9;
    }
}

データ整合性チェック

移行後の検証

// app/Console/Commands/CheckDataIntegrity.php
class CheckDataIntegrity extends Command
{
    public function handle()
    {
        $checks = [
            'レコード数の検証' => $this->checkRecordCounts(),
            '文字化けチェック' => $this->checkEncodingIssues(),
            '外部キー整合性' => $this->checkForeignKeys(),
            'NULL値チェック' => $this->checkNullValues(),
            '重複データチェック' => $this->checkDuplicates(),
        ];
        
        $this->table(
            ['チェック項目', '結果', 'エラー数'],
            collect($checks)->map(function ($result, $check) {
                return [
                    $check,
                    $result['passed'] ? '✅ OK' : '❌ NG',
                    $result['errors'],
                ];
            })->toArray()
        );
    }
    
    private function checkRecordCounts()
    {
        $expected = [
            'users' => 3000,
            'programs' => 1500,
            'news' => 15000,
        ];
        
        $errors = 0;
        foreach ($expected as $table => $count) {
            $actual = DB::table($table)->count();
            if (abs($actual - $count) > $count * 0.01) { // 1%の誤差を許容
                $errors++;
            }
        }
        
        return [
            'passed' => $errors === 0,
            'errors' => $errors,
        ];
    }
}

パフォーマンスの最適化

バルクインサートの実装

// 大量データの効率的な挿入
class BulkDataMigrator
{
    private const CHUNK_SIZE = 1000;
    
    public function migrate($sourceTable, $targetTable, $transformer)
    {
        $oldDb = DB::connection('old_mysql');
        
        $oldDb->table($sourceTable)
            ->orderBy('id')
            ->chunk(self::CHUNK_SIZE, function ($records) use ($targetTable, $transformer) {
                $data = [];
                
                foreach ($records as $record) {
                    $data[] = $transformer($record);
                }
                
                // バルクインサート
                DB::table($targetTable)->insert($data);
                
                // メモリ解放
                unset($data);
                
                $this->info("処理済み: " . count($records) . "件");
            });
    }
}

メモリ管理

// メモリ効率的な処理
ini_set('memory_limit', '512M');

// ジェネレータを使用した省メモリ処理
function readLargeFile($file)
{
    $handle = fopen($file, 'r');
    
    while (!feof($handle)) {
        yield fgets($handle);
    }
    
    fclose($handle);
}

トラブルシューティング

よくあった問題と解決策

問題 原因 解決策
文字化け 複数のエンコーディング混在 自動検出と個別対応
外部キー制約エラー 参照先データの欠損 移行順序の調整
メモリ不足 大量データの一括処理 チャンク処理の実装
重複データ 一意性制約の欠如 事前チェックと統合処理

まとめ

データ移行は予想以上に困難でしたが、Claude Codeとの協働により効率的に解決できました:

  • 段階的移行により、リスクを最小化
  • 文字コード問題に対する多層的な対策をAIと共に構築
  • 65個のArtisanコマンドを自動生成し、再現性を確保
  • データ整合性チェックで、品質を保証
  • パフォーマンス最適化で、現実的な時間内に完了

AI駆動開発で得られた教訓

  1. AIは強力な補助ツール:複雑な変換ロジックの実装を大幅に効率化
  2. 人間の検証は必須:特に文字コードやデータ整合性は目視確認が重要
  3. 段階的なアプローチ:AIの提案を小さく試し、問題があれば修正を繰り返す
  4. ドメイン知識の重要性:業務特有の仕様はAIに明示的に伝える必要がある

特に文字コード問題は、日本のレガシーシステムに共通する課題です。AIツールを活用することで、従来なら数週間かかる作業を数日で完了できました。

次回予告

次回の記事では、以下の内容をお届けします:

  • リアルタイムチャット機能の実装
  • AI連携によるゲスト情報解析
  • Twitter API v2との連携
  • 週単位時間システムの実装

お楽しみに!

Discussion