ChMate(Androidの5ch専ブラ)のバックアップZIP内の過去ログデータを、サーバーに一切送らずブラウザ上で閲覧できるビューワーを作ってみました。
すべてのきっかけはAndroidからiPhoneへの機種変だった
私事にはなりますが、昨年なんと8年ぶりにAndroidからiPhone17へと機種変しました。
ご存知の方も多いとは思いますが、スマホにおける専ブラ序列はChMate一強との主張が非常に多い。筆者もずっと愛用していたが、確かにあのカスタマイズ性はAndroidならではの強みだと考えている。なのでこの為だけにAndroidと二台持ちしてるという人も。
そうして手元に残ったのは、律儀に取っておいたバックアップZIPが1つだけ。当然の事ながらこれはMate用にインポートするぐらいしか使い道がない。
なんとか同じUIの質感で読めないか・・・と思い、それだけの為に作りました。
できること
- ZIPを選ぶだけで板/スレ/レスをブラウズ(
dat/の現行ログとarchive/の過去ログの両方) - レスアンカーのポップアップとツリー表示、ID別の抽出
- NG(名前・本文・ID)、ブックマーク、「ここまで読んだ」の記憶
- ZIP横断の全文検索
- AA(アスキーアート)をモナーフォント同梱で崩さず表示
- PWAによってiOSのホーム画面に追加して全画面でも使用可能
当然のことながらこの手のログは他人に見せたいものでもないので、サーバー不使用でのデータ保持(IndexedDB)によって永続化され、すべてがブラウザ内で完結するシステムになってます。
技術構成
| 項目 | 内容 |
|---|---|
| フロント | React 19 + TypeScript + Vite |
| スタイル | Tailwind CSS 4 |
| ZIP展開 | JSZip |
| 永続化 | IndexedDB / localStorage / Cookie |
| ルーティング |
history.pushState + popstate の自前実装 |
| デプロイ | GitHub Actions → GitHub Pages |
本題:IndexedDBが優秀すぎる
このアプリの体験を成立させているのは、ほぼIndexedDBです。
1. ArrayBufferをそのまま置ける
localStorageは文字列しか入らず、上限も5MB程度。ZIPを入れるならBase64化が必要で、それだけで容量が約1.33倍に膨らみます。
しかしなんとIndexedDBは構造化クローンなので、File.arrayBuffer() の結果をシリアライズなしでそのままputできます。
const data = await file.arrayBuffer();
const tx = db.transaction('zip', 'readwrite');
tx.objectStore('zip').put(
{ filename: file.name, data, savedAt: Date.now(), sizeBytes: file.size },
'current'
);
容量もlocalStorageのような固定の小さな上限ではなく、ストレージ割り当ての範囲まで使えます。
おかげでZIPを丸投げして保存でき、わざわざ都度ファイルを読み込んだりする必要はありません。
2. 「重い前処理のキャッシュ」がそのまま置ける
初回読み込みでは、全スレッドのDATの1行目を開いてタイトルとレス数を取り出します。スレッドが数千あると、これがそのまま待ち時間になります。
そこで結果をthread-metaストアにキャッシュし、2回目以降はキャッシュヒットしたスレッドのDATを一切開かないようにしました。
for (const thread of threads) {
const cached = metaCache[thread.key];
if (cached) {
thread.title = cached.title;
thread.resCount = cached.resCount;
}
}
// キャッシュミスした分だけDATを開く
const noTitle = threads.filter(t => !t.title);
「重い処理の結果をオブジェクトのまま置いておける」というだけで、設計の選択肢がかなり広がります。
3. APIは言うほど怖くない
コールバックベースで冗長と言われがちですが、openDB()を1つPromiseでラップしてしまえば、あとは用途ごとにストアを足すだけです。
ZIP本体・スレメタ・既読位置の3ストアで、永続化まわりは全部で140行程度に収まりました。ストアを増やすときはDB_VERSIONをバンプしてonupgradeneededで作るだけです。
一方で、ブックマークや表示設定のような小さくて同期的に読みたい値はlocalStorageのままにしています。全部IndexedDBに寄せると、初期表示が非同期待ちだらけになるためです。
ハマったところ
文字コードとDAT形式の差
5chはShift_JIS、したらばはEUC-JPです。TextDecoderが両方を扱えるので、ホスト名から判定して渡すだけで済みました。
const decoder = new TextDecoder(encoding, { fatal: false });
厄介なのはDATのフィールド構成で、5chは5フィールド、したらばは先頭にレス番が付いた7フィールドです。1行目が数字+<>で始まるかで判別しています。
crypto.randomUUID() はHTTPSでしか動かない
これが一番時間を溶かしました。開発中にLAN内のHTTPで実機確認したところ、コンソールにTypeError: crypto.randomUUID is not a functionというエラーが。
調べてみるとSecure Context(HTTPSまたはlocalhost)限定のAPIだったためです。uuidライブラリのv4()に置き換えて解決しました。
おわりに
実装の大部分はClaude Codeと一緒に書きました。
DATのパースやIndexedDBのラッパーのような仕様が明確で退屈な部分はAIが速く、逆に「AAをどこまで自動判定するか」「どの値をlocalStorageに残すか」といった基準が曖昧な部分は自分で決める必要がある…という分担に落ち着いています。
私と同じようにバックアップZIPを持て余している方は、上のデモにドロップしてみてください。