Google マイアクティビティの履歴をブラウザだけで分析したくて、DuckDB WASMとOrigin Private File System (OPFS) を組み合わせた Web アプリを作りました。 この記事ではアプリの紹介は軽めに、ブラウザ完結のデータ基盤をどう構成したのかを中心に振り返ります。 開発したアプリとソースコードは、以下になります。
Google Takeout からエクスポートしたマイアクティビティの ZIPファイル をアプリにアップロードすると、ブラウザ内で以下の可視化を眺められます。
サーバーへは何も送らず、データは手元のブラウザに閉じたまま。
Google が保持している元データは My Activity で確認できます。
6年前に Chrome の検索履歴でワードクラウドを作ったときは、ブラウザの何かしらのStorageに入れていました。
当時の実装はもう覚えていませんが(笑)、いまならブラウザだけでどこまでやれるのか試したくなりました。 ブラウザにDBを持つという素人が参院議員の議案賛否検索サイトを作ってみた - zennを読んでいたこともあり、 「DuckDB WASM ならローカル DB をそのまま扱えそう」と感じたのがきっかけです。MySQL など他の選択肢も考えたものの、個人開発なので気になった技術を試しました。
アプリは手慣れている T3 Stack で構築しています。 Next.js は既存知識で片付け、今回はブラウザ内データ基盤の検証に集中しました。
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 ファイルとして切り出し、インポートしています。
-- sample.sql
SELECT *
FROM activities
LIMIT 10;// SomeComponent.tsx
import sampleSql from "./sample.sql";
const rows = await runQuery(sampleSql);SQL をファイルとして保存しておくとフォーマッタをかけやすく、差分レビューもしやすいので気に入っています。 ブラウザ内で完結するプロダクトでも、サーバーサイドと同じく SQL を直接管理できるのが地味に嬉しいポイントでした。
Google Takeout の ZIPファイル をドラッグ&ドロップすると、ZIPファイル を展開した JSONファイル を registerFileText 経由で DuckDB に登録します。
DuckDBにデータを取り込むには、2つのステップを踏みます。
registerFileText で、ローカルファイルシステムにデータをインポートします。他の方法については、以下のドキュメントを参考になります。
私は、以下のように実装しました。
-- 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=... からストレージをクリアしています。
ブラウザ上の DuckDB に対して SQL をそのまま流し込める体験は、なかなか新鮮なものでした。 レイテンシはほぼ感じないので 「自分のマシンで分析している」 感覚が強く、SQL を書くのが好きな身としてはかなり楽しい時間でした。 アプリ内には簡易的な SQL Viewer も用意してあり、クエリをその場で投げて結果を確認できるようにしています。
DuckDB WASM と OPFS を使ってみたのは初めてでしたが、セットアップさえ乗り越えればブラウザ完結でも十分に分析体験を作れると実感しました。 また一つ、世界が広がりました。
タグ「開発ツール」の記事
Million.devを知り、少し試してみました。Million.jsについて このライブラリは、React DevToolsのProfilerより簡単にプロファイリングできるみたいです。 パフォーマンスのプロファイリングは通常、面倒で時間のかかる作業です。もしもこれを簡単に実行できるのであれば、めちゃくちゃ捗るなとわくわくしました。
あけまして、おめでとうございます。神社のおみくじで、人生はじめて大吉を引きました、silverbirder です。普段の業務で、FigmaのデザイントークンやAPIのスキーマファイル、i18nのメッセージファイルなどを、フロントエンドへ同期するコミュニケーションが不毛に感じています。そこで、GitHub ActionsとPull Requestを活用して、同期コミュニケーションを削減する仕組みを紹介します。
WikiWikiWeb というコンセプトが好きで、そのコンセプトが含まれている Obsidian や Scrapbox が好きです。Obsidian には、obsidian-gitという Git 連携のプラグインがあります。こちらには、デスクトップだけでなく、モバイルからでも Git Commit できるようになりました。
タグ「ブラウザ」の記事
こんにちは、@silverbirderです。最近、湖県に移住してWebフロントエンドのお仕事をしています。お仕事をしていると、ユーザー体験を良くするためには、大きな改善をせずとも小さな改善だけでも十分な効果があると思い始めました。本記事では、その小さな改善となる、3つのことについて書きたいと思います。
ブログ記事のOGP画像に、ブログタイトルを入れたい場面があります。その際、タイトルが長い場合は複数行に分けたり、省略したりする必要があります。今回は、試してみてよさそうだった2つの方法を紹介します。
2025-02-06
最近、ビアードパパの焼きチーズケーキシューにハマっている silverbirder です。文章作成が苦手な私は、AI が文章を代筆する「AI Ghostwriter」という Chrome の拡張機能を開発しました。今回は、この便利なツールの紹介をします。