はじめに
オプチャグラフは、LINEオープンチャットの統計情報を収集・分析・可視化するWebサービスです。
2023年10月にLINEがオープンチャット公式サイトを公開して以来、約2年間にわたりデータを収集し続けています。本記事では、このサービスを支えるデータパイプラインの全体像を解説します。
収集データの規模(2025年12月現在)
| データ | 件数 |
|---|---|
| 収集済みオープンチャット | 約25万件 |
| メンバー数の日次統計 | 約9,100万レコード |
| ランキング履歴 | 約8,900万レコード |
| 合計 | 約1.8億レコード |
これらのデータを、月額1,000円程度のレンタルサーバーで運用しています。
システム全体像
[LINE公式サイト API]
↓ (1) クローリング(25カテゴリ × 2種類)
[APIレスポンス JSON]
↓ (2) バリデーション・DTO変換
[OpenChatDto]
↓ (3) 差分検出(メモリキャッシュと比較)
[OpenChatUpdaterDto]
↓ (4) 差分のみDB更新
[MySQL / SQLite]
↓ (5) 静的ファイル生成(タクソノミーごと)
[キャッシュファイル群]
↓
[ユーザー]
(1) クローリング:LINE公式APIからのデータ取得
LINEオープンチャット公式サイトは、内部的にJSONを返すAPIを使用しています。このAPIは公式ドキュメントが存在しないため、ブラウザの開発者ツールで解析しました。
APIの構造
https://openchat.line.me/api/category/{カテゴリID}?sort={RANKING|RISING}&limit=40&ct={継続トークン}
- カテゴリ: 25種類(ゲーム、スポーツ、芸能人・有名人など)
-
ソート:
RANKING(人気順)とRISING(急上昇)の2種類 -
ページネーション: continuation token方式(
ctパラメータ)
1回のリクエストで最大40件を取得し、レスポンスに含まれる継続トークンを使って次のページを取得します。これを全カテゴリ×2種類で繰り返すと、約25万件のデータが取得できます。
関連コード:
- OpenChatApiRankingDownloader.php - ページネーション処理
- OpenChatCrawlerConfig.php - APIエンドポイント定義
レートリミットとの付き合い方
当初は並列処理で高速化を試みましたが、LINEのサーバーから429(Too Many Requests)が返されるようになったため、現在は順次処理で実行しています。全カテゴリの取得には約10〜15分かかりますが、安定運用を優先しています。
関連コード:
- OpenChatApiDbMerger.php - 現在使用している順次処理版
(2) バリデーションとDTO変換
APIから取得したJSONは、そのままでは使いにくい構造をしています。これを扱いやすい形に変換するのがDTOファクトリの役割です。
// APIレスポンスの構造
{
"squaresByCategory": [{
"category": { "id": 17 },
"squares": [{
"square": {
"emid": "xxx",
"name": "チャット名",
"desc": "説明文",
"profileImageObsHash": "画像ハッシュ",
"emblems": [1],
"joinMethodType": 0
},
"memberCount": 1234,
"createdAt": 1234567890000
}]
}]
}
これをOpenChatDtoというシンプルなオブジェクトに変換します。この段階でバリデーションも行い、不正なデータは除外してログに記録します。
関連コード:
- OpenChatApiDtoFactory.php - JSONからDTOへの変換
(3) 差分検出:25万件のメモリキャッシュ
25万件を毎時取得しても、実際に内容が変わっているのは数千件程度です。全件をUPDATEするのは無駄なので、変更があったデータだけを検出します。
アプローチ
- クローリング開始時に、DBの全データ(25万件)をメモリに読み込む
- APIから取得した各データについて、emid(LINE内部ID)でメモリ上のデータを検索
- 名前・説明・画像・カテゴリ・バッジ・参加方法を比較
- 1つでも違いがあれば「更新対象」としてマーク
// 差分検出の判定ロジック(OpenChatApiDbMergerProcess.php より)
if (
$repoDto->name === $apiDto->name
&& $repoDto->desc === $apiDto->desc
&& $repoDto->profileImageObsHash === $apiDto->profileImageObsHash
&& $repoDto->category === $apiDto->category
&& $repoDto->emblem === $apiDto->emblem
&& $repoDto->joinMethodType === $apiDto->joinMethodType
) {
return null; // 変更なし、何もしない
}
この設計により、DBへの書き込みを大幅に削減しています。
関連コード:
- OpenChatDataForUpdaterWithCacheRepository.php - 25万件メモリキャッシュ
- OpenChatApiDbMergerProcess.php - 差分検出ロジック
(4) MySQLとSQLiteの使い分け
1.8億レコードをすべてMySQLに入れると、レンタルサーバーの容量制限(数GB)を超えてしまいます。そこで、データの特性に応じてDBを使い分けています。
| 特性 | MySQL | SQLite |
|---|---|---|
| 用途 | 頻繁に更新されるデータ | 追記のみの時系列データ |
| 例 | オープンチャットマスタ(25万件) | メンバー数統計(9,100万件) |
| 操作 | INSERT / UPDATE / DELETE | INSERT のみ |
| 容量 | 制限あり | ファイルベースで制限なし |
SQLiteはファイルベースなので、レンタルサーバーの容量制限を気にせず使えます。また、追記のみのデータなら書き込み競合も発生しません。
関連コード:
- SqliteStatisticsRepository.php - SQLite統計リポジトリ
(5) 静的ファイル生成:タクソノミーごとのキャッシュ戦略
ランキングページは、全ユーザーが同じデータを見ます。アクセスのたびにDBクエリを実行するのは無駄なので、あらかじめ静的ファイルとして生成しておきます。
キャッシュの構造
オプチャグラフでは、以下のタクソノミー(分類軸)ごとにキャッシュファイルを生成しています。
/storage/ja/
├── static_data_top/ # トップページ用
│ ├── ranking_list.dat # 成長ランキング(1時間/24時間/週間/人気)
│ ├── ranking_arg_dto.dat # ランキング表示用メタデータ
│ ├── recommend_page_dto.dat # おすすめページ用メタデータ
│ └── tag_list.dat # 全タグ一覧
│
└── static_data_recommend/ # 各ページ埋め込み用
├── tag/ # タグ別ランキング(634ファイル)
│ ├── {hash}.dat # 例: "ゲーム実況" → crc32ハッシュ
│ └── ...
├── category/ # カテゴリ別ランキング(25ファイル)
│ ├── 17.dat # ゲーム
│ ├── 16.dat # スポーツ
│ └── ...
└── official/ # 公式認証バッジ別(2ファイル)
├── 1.dat # スペシャル
└── 2.dat # 公式認証
トップページのキャッシュ内容
StaticTopPageDto には以下のデータが含まれます:
class StaticTopPageDto
{
public array $hourlyList; // 1時間成長ランキング
public array $dailyList; // 24時間成長ランキング
public array $weeklyList; // 週間成長ランキング
public array $popularList; // メンバー数ランキング
public array $recommendList; // おすすめリスト
public int $tagCount; // 総タグ数
}
タクソノミー別キャッシュの生成
各タクソノミーごとに、そのランキングデータをシリアライズしてファイルに保存します。
// RecommendStaticDataGenerator.php より
private function updateRecommendStaticData()
{
// 634個のタグそれぞれについてキャッシュ生成
foreach ($this->getAllTagNames() as $tag) {
$fileName = hash('crc32', $tag); // タグ名をハッシュ化してファイル名に
saveSerializedFile(
AppConfig::getStorageFilePath('recommendStaticDataDir') . "/{$fileName}.dat",
$this->getRecomendRanking($tag)
);
}
}
private function updateCategoryStaticData()
{
// 25カテゴリそれぞれについてキャッシュ生成
foreach (AppConfig::OPEN_CHAT_CATEGORY as $category) {
saveSerializedFile(
AppConfig::getStorageFilePath('categoryStaticDataDir') . "/{$category}.dat",
$this->getCategoryRanking($category)
);
}
}
ページ表示時の読み込み
ページ表示時は unserialize() でDTOを復元するだけ。DBアクセスは一切発生しません。
// キャッシュから読み込み
$dto = getUnserializedFile(AppConfig::getStorageFilePath('topPageRankingData'));
関連コード:
- StaticDataGenerator.php - トップページ用静的データ生成
- RecommendStaticDataGenerator.php - タクソノミー別静的データ生成
- AppConfig.php - ストレージパス定義
キャッシュの更新タイミング
クローリング完了後、以下の順序でキャッシュを更新します:
// SyncOpenChat.php より
$this->hourlyMemberRanking->update(); // ランキング計算
$this->staticDataGenerator->updateStaticData(); // キャッシュ再生成
関連コード:
- SyncOpenChat.php - 毎時実行されるメイン処理
まとめ
オプチャグラフのデータパイプラインは、以下の工夫で低コスト運用を実現しています。
- 差分検出: 変更があったデータだけをDBに書き込む
- DB使い分け: 更新頻度でMySQL/SQLiteを選択
- タクソノミー別キャッシュ: トップページ、タグ(634個)、カテゴリ(25個)、公式認証(2個)それぞれに静的ファイルを生成
次回の記事では、差分検出を支えるDTO設計について詳しく解説します。