Sジブンノート

DuckDB WASMとOPFSでGoogleマイアクティビティをブラウザ完結で可視化してみた

Google マイアクティビティの履歴をブラウザだけで分析したくて、DuckDB WASMとOrigin Private File System (OPFS) を組み合わせた Web アプリを作りました。 この記事ではアプリの紹介は軽めに、ブラウザ完結のデータ基盤をどう構成したのかを中心に振り返ります。 開発したアプリとソースコードは、以下になります。

アプリのざっくり紹介

Google Takeout からエクスポートしたマイアクティビティの ZIPファイル をアプリにアップロードすると、ブラウザ内で以下の可視化を眺められます。

  • 検索キーワードのワードクラウド
  • 月別のアクティビティヒートマップ
  • 時間帯ごとの行動パターン
  • 位置情報ベースのマップ表示

サーバーへは何も送らず、データは手元のブラウザに閉じたまま。
Google が保持している元データは My Activity で確認できます。

DuckDB WASMを選んだ理由

6年前に Chrome の検索履歴でワードクラウドを作ったときは、ブラウザの何かしらのStorageに入れていました。

当時の実装はもう覚えていませんが(笑)、いまならブラウザだけでどこまでやれるのか試したくなりました。 ブラウザにDBを持つという素人が参院議員の議案賛否検索サイトを作ってみた - zennを読んでいたこともあり、 「DuckDB WASM ならローカル DB をそのまま扱えそう」と感じたのがきっかけです。MySQL など他の選択肢も考えたものの、個人開発なので気になった技術を試しました。

技術スタックの前提

アプリは手慣れている T3 Stack で構築しています。 Next.js は既存知識で片付け、今回はブラウザ内データ基盤の検証に集中しました。

DuckDBとOPFSを繋いだ初期化

DuckDB WASM は npm の @duckdb/duckdb-wasm パッケージを利用します。 バンドルされた duckdb-eh.wasm とワーカーを public/ に配置し、初期化時に手動で読み込みます。

// src/contexts/duck-db/duck-db.hook.ts
import * as duckdb from "@duckdb/duckdb-wasm";

const MANUAL_BUNDLES = {
  mvp: {
    mainModule: "/duckdb-eh.wasm",
    mainWorker: "/duckdb-browser-eh.worker.js",
  },
};

const bundle = await duckdb.selectBundle(MANUAL_BUNDLES);
const worker = new Worker(bundle.mainWorker!);
const db = new duckdb.AsyncDuckDB(new duckdb.ConsoleLogger(), worker);

await db.instantiate(bundle.mainModule, bundle.pthreadWorker);
await db.open({
  path: "opfs://google-myactivity-visualization.db",
  accessMode: duckdb.DuckDBAccessMode.READ_WRITE,
});

opfs:// を指定すると DuckDB 側が OPFS 上のデータベースファイルを面倒見てくれるので、ページをリロードしてもテーブルが残ります。 今回、OPFS自体の操作はDuckDB WASM側で処理されていて、Web APIを直接叩いていませんが、以下のドキュメントは一読しておきました。

上記ドキュメントから、OPFS は HTTPS が前提と知り、ローカル開発は next dev --experimental-https で起動しました。

アプリ側ではこの初期化処理を React Context に閉じ込め、runQuery などのユーティリティ経由で DuckDB を参照します。

// src/contexts/duck-db/duck-db.hook.ts
export const runQuery = async (query: string) => {
  const conn = await db.connect();
  try {
    const result = await conn.query(query);
    return result.toArray();
  } catch (err) {
    const message = err instanceof Error ? err.message : String(err);
    throw new Error(`Query failed: ${message}`);
  } finally {
    await conn.close();
  }
};

React Context から runQuery を各コンポーネントに渡すことで、ビュー側からは SQL を書くだけで済む形にしました。

SQLはファイルで管理

集計ロジックは SQL ファイルとして切り出し、インポートしています。

-- sample.sql
SELECT *
FROM activities
LIMIT 10;
// SomeComponent.tsx
import sampleSql from "./sample.sql";

const rows = await runQuery(sampleSql);

SQL をファイルとして保存しておくとフォーマッタをかけやすく、差分レビューもしやすいので気に入っています。 ブラウザ内で完結するプロダクトでも、サーバーサイドと同じく SQL を直接管理できるのが地味に嬉しいポイントでした。

ZIP/JSONをブラウザ内で取り込む

Google Takeout の ZIPファイル をドラッグ&ドロップすると、ZIPファイル を展開した JSONファイル を registerFileText 経由で DuckDB に登録します。 DuckDBにデータを取り込むには、2つのステップを踏みます。

  1. registerFileText で、ローカルファイルシステムにデータをインポートします。
  2. SQLを実行して、テーブルに挿入します。

他の方法については、以下のドキュメントを参考になります。

私は、以下のように実装しました。

-- create_activities.sql
CREATE TABLE IF NOT EXISTS activities AS
SELECT
  json_extract_string(json, '$.title') AS title
FROM read_json_objects('__PATH__');
-- insert_activities.sql
INSERT INTO activities
SELECT
  json_extract_string(json, '$.title') AS title
FROM read_json_objects('__PATH__');
// handleFile.ts
import createActivitiesSql from "./create_activities.sql";
import insertActivitiesSql from "./insert_activities.sql";

const path = `mem://activities_${Date.now()}.json`;
const jsonText = JSON.stringify(activities);

await db.registerFileText(path, jsonText);

const escapedPath = path.replaceAll("'", "''");
await runQuery(createActivitiesSql.replace("__PATH__", escapedPath));
await runQuery(insertActivitiesSql.replace("__PATH__", escapedPath));

SQLファイル側では'__PATH__'のようにシングルクォート付きでプレースホルダを置いておき、最後にエスケープ済みのパスを差し込む形にしています。

read_json_objectsを使うと巨大な配列もうまく処理してくれるので、150MB超の履歴でもブラウザが固まらずに済みました。 ZIP内のJSONは1件ずつ進捗を出しながら挿入しているので、待ち時間の不安も軽減されています。
挿入後はdb.flushFiles()でメモリ上の一時ファイルを掃除しておくと安心です。

データ削除とデバッグメモ

全データを消したいときは TRUNCATE TABLE activities; を投げてリセットしています。 念のため db.reset() も呼んで DuckDB WASM の状態をリフレッシュする運用です(理由は体験ベース)。

DevTools の Application タブでは OPFS のファイルを削除できなかったので、chrome://settings/content/siteDetails?site=... からストレージをクリアしています。

ブラウザで SQL を書く楽しさ

ブラウザ上の DuckDB に対して SQL をそのまま流し込める体験は、なかなか新鮮なものでした。 レイテンシはほぼ感じないので 「自分のマシンで分析している」 感覚が強く、SQL を書くのが好きな身としてはかなり楽しい時間でした。 アプリ内には簡易的な SQL Viewer も用意してあり、クエリをその場で投げて結果を確認できるようにしています。

おわりに

DuckDB WASM と OPFS を使ってみたのは初めてでしたが、セットアップさえ乗り越えればブラウザ完結でも十分に分析体験を作れると実感しました。 また一つ、世界が広がりました。