TL;DR
- 毎朝Claudeに開発ネタを自動で考えてもらってNotionにストックする仕組みを作っていて、その中の1案から生まれたのが「TabMuse」というChrome拡張です
- 複数選択したタブをまとめて要約し、カテゴリごとに色分けしたタブグループへ自動整理してくれるツールで、要約にはChromeの内蔵AI(Built-in AI)を使っています
- Chromeウェブストアの審査を通過し、無料枠+買い切りPro(ExtensionPay/Stripe)というシンプルな構成で公開済み。手応えはまだこれからです
きっかけ
もともと、思い付きをそのまま眠らせてしまわないように、毎朝Claudeに開発ネタを自動で考えてもらってNotionに保存する仕組みを作っていました。日々そこに溜まっていく案の中の1つが、今回のTabMuseです。
「タブを開きすぎて収拾がつかなくなる」というのはよくある話ですが、今回はその課題自体を自分で強く意識していたというより、ストックされていた案の中からこれは形にできそうだと選んだ、という経緯に近いです。
やったこと
TabMuseは、複数選択したタブを右クリックからまとめて要約し、内容ごとに色分けされたタブグループへ自動で整理してくれるChrome拡張です。
- 要約にはChromeの内蔵AI(Built-in AI)を利用し、外部APIに頼らずブラウザ内で処理
- 要約結果をもとにカテゴリ分けし、色付きのタブグループへ自動整理
- 無料枠は1日5回までの要約、それ以上使いたい場合は買い切りのProアップグレード(ExtensionPay経由でStripe決済)
Chromeウェブストアの審査を通過し、現在は一般公開されています。
技術的な深掘り:ExtensionPayで買い切りProを実装する(MV3 + Vite)
一番手こずったのは課金・決済まわり、具体的にはExtensionPay(npm: extpay)を使った買い切り(one-time)Proの実装でした。拡張機能自体のロジックよりも、こちらの設計・実装に時間を使うことになったので、ここだけ詳しく書きます。対象はManifest V3・Service Worker・Vite前提です(公式: Glench/ExtPay)。
全体像
決済〜Pro反映までの流れは次のとおりです。
[サイドパネル] 「Proにアップグレード」
→ ExtPay.openPaymentPage()
→ 新しいタブでextensionpay.com(+Stripe)
[ユーザーが支払い完了]
→ extensionpay.com上のcontent script(bridge)がpostMessage
→ Service WorkerのExtPay.onPaid
→ chrome.storageにpaidをキャッシュ
→ UIに「Proになった」と通知
[機能側]
→ isPro?を見るときは必ず「キャッシュ付きgetUser」経由
→ Proなら無料枠をスキップ
ポイントは次の3つです。
-
startBackground()はService Worker起動時だけ呼ぶ(サイドパネルからは呼ばない) - 決済完了検知には
extensionpay.com向けcontent scriptが必須 - Pro判定は1関数に集約し、機能コードからExtPayを直接呼ばない
manifest.jsonで必須の2設定
決済完了をbackgroundに届ける橋として、content scriptの登録が必要です。
"content_scripts": [
{
"matches": ["https://extensionpay.com/*"],
"js": ["content/extpay-bridge.js"],
"run_at": "document_start"
}
]
run_at: "document_start" を忘れると、content scriptの読み込みが遅れて onPaid が発火しないことがあります。中身はほぼ1行です。
// content/extpay-bridge.ts
import 'extpay'
もう1つ、getUser() などが https://extensionpay.com へfetchするため、拡張ページのCSPで止めないようにします。
"content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'self'; connect-src 'self' https://extensionpay.com https://*.extensionpay.com;"
}
script-src 'self' はそのままにしています。ExtensionPayへはAPI通信と決済タブを開くだけで、拡張ページ側でリモートJSを実行する必要はありません。
課金ラッパーは1モジュールに集約
ExtPay呼び出しは1モジュールにまとめ、外からは次の関数だけ見えるようにしました。
| 関数 | 役割 |
|---|---|
startPaymentsBackground() |
SW専用。startBackground() を1回だけ呼ぶ |
onPaidStatusChange(cb) |
支払い完了時にキャッシュ更新+UI通知 |
checkPaidStatus() |
ネット経由で getUser() しキャッシュ更新 |
getCachedPaidStatus() |
通常のPro判定(TTL+失敗時フォールバック) |
openUpgradePage() |
決済タブを開く |
startBackground() はService Workerのトップレベルで1回だけ呼びます。サイドパネルや onMessage の中から呼ぶと、公式ドキュメントどおり不安定になります。
let backgroundExtPay = null
let backgroundStarted = false
export function startPaymentsBackground() {
if (backgroundStarted) return
backgroundStarted = true
backgroundExtPay = ExtPay('my-extension-id') // ダッシュボードのIDと一致させる
backgroundExtPay.startBackground()
}
onPaid はこの backgroundExtPay と同じインスタンスに登録し、支払い完了時にstorageへ書き込みます。
export function onPaidStatusChange(callback) {
if (!backgroundExtPay) startPaymentsBackground()
backgroundExtPay.onPaid.addListener((user) => {
const paid = Boolean(user.paid)
void writePaidCache(paid).then(() => callback(paid))
})
}
一方、openPaymentPage() や getUser() はコールバック安全性のため、呼ぶたびに新しく ExtPay(id) を生成する形にしています。MV3のService Workerはよく休眠するため、モジュールスコープの変数だけに依存すると、コールバック後に参照が消えることがあるからです。
Pro判定はキャッシュ付きにして、オフライン耐性を持たせました。
- ストレージキーに
{ paid: boolean, checkedAt: number }を保存 - TTL15分、TTL内はキャッシュを返す
- TTL切れなら
getUser()。失敗したら直近キャッシュ、それも無ければfalse
export async function getCachedPaidStatus() {
const cached = await readPaidCache()
const now = Date.now()
if (cached && now - cached.checkedAt < 15 * 60 * 1000) {
return cached.paid
}
try {
return await checkPaidStatus()
} catch {
if (cached) return cached.paid
return false
}
}
機能側(要約処理など)はExtPayを直接触らず、この getCachedPaidStatus() 経由の判定だけを見るようにして、Pro判定の入口を1箇所に寄せています。
ハマりどころ
実装中に実際につまずいたのは、主に次のパターンでした。
| 症状 | よくある原因 | 対策 |
|---|---|---|
| テスト決済は成功したのに無料のまま | bridgeのcontent scriptが動いていない/成果物に extpay-bridge.js が無い/UIが更新イベントを購読していない |
manifestの matches/run_at を確認、ビルド成果物にbridge JSが実在するか確認 |
getUser() が失敗し常にfalse |
CSPの connect-src 不足/Extension ID不一致 |
manifestのCSPにExtensionPayドメインを追加、ダッシュボードとコードのIDをコピペで突き合わせ |
| 決済ページが開かない |
openPaymentPage() のPromiseを握りつぶしている |
.catch() でログを出し、ユーザー操作のスタック内から呼んでいるか確認 |
| 再読み込みでProが消える/逆に消えない |
onPaid の瞬間だけメモリに持ってstorageに書いていない/ExtPayサーバー側の状態が残っている |
onPaid と getUser() 成功時に必ず chrome.storage.local へ保存。テストのやり直しはExtensionPay側のテストユーザー取り消しか別プロファイルで対応 |
| デバッグビルドはPro、本番は無料(またはその逆) | 開発用のPro判定モックが本番判定に混ざっている | 本番は getCachedPaidStatus() のみで判定し、モックはビルドモードでのみ許可 |
Chromeウェブストアの審査では「外部URLからscriptを動的読み込みしていないか」を聞かれそうなポイントでもあります。TabMuseの場合はExtensionPayへの通信はHTTPS APIと決済タブを開くだけで、拡張ページの script-src は 'self' のまま、eval やリモートJSの実行はしていません。ここを整理しておくと説明がしやすくなります。
結果・現状
公開したばかりで、利用者の反応や実際の使われ方はまだこれから様子を見ていく段階です。数値的な成果はまだ語れる段階になく、今後の反応次第で機能や課金体系を見直していく予定です。
まとめ・今後
「毎朝ネタを自動生成してストックする→その中から形にできそうなものを選んで実装する」という流れで、実際に1本リリースまで持っていけた事例になりました。今回はExtensionPay連携が一番の学びどころで、manifestのcontent script/CSP設定、SW起動時だけの startBackground、キャッシュ付きのPro判定に集約する、という3点さえ押さえれば次はもう少しスムーズにできそうです。公開後の反応を見ながら、機能追加やPro版の見直しも検討していきます。