Mintor開設!初回からStripe(スプライト)初期設定の「罠」に詰まった話
みなさん、こんにちは。 Mintorのβ版、開設おめでとうございます! 開発者同士をつなぐ新しいプラットフォームということで、これから楽しみに活用していきたいと思います。 普段はnoteをメインに執筆しているのですが、今回はMintorの初回記事として、さっそく私が「初期設定(決済連携)で派手に詰まったポイント」を共有します。 これから登録する新人さんや、同じように困っている方の参考になれば幸いです。 何に詰まったのか?:Stripe(スプライト)の連携問題 Mintorの初期設定を進める中で、決済まわりの設定として「Stripe(スプライト)」の連携を求められます。 実は、私はすでにStripeの既存アカウント(ID)を持っています。 そのため、「既存アカウントをサクッと紐付けたいだけ」だったのですが、ここで迷子になってしまいました。 迷子になった3つの理由 全体像(フロー)が見えない:次に何が起こるのかのロードマップが見えない。 ドキュメントが見つからない:ヘルプや仕様が書かれたページがパッと見つからない。 「次へ」を押すと新規取得画面っぽいフェーズになる:既存ユーザー用のログイン導線が見当たらず、「次へ」「次へ」と進む一本道しか用意されていないように見える。 既存アカウントを持っている人のための「直接設定画面」や「既存アカウントでログイン」というボタンが手前にないため、「このまま新規作成のフローに進んでいいのだろうか…?」と手が止まってしまっている状態です。 開発者プラットフォームにこの「詰まり」を投げる理由 UI/UXの観点から見ても、最初のオンボーディング(登録導線)でユーザーが迷子になるのはよくあることです。私自身がまさに今リアルタイムで詰まっているので、ここを通過した先輩エンジニアの方や、運営の方に届くといいなと思い、あえて初回の記事としてこの「困りごと」を公開してみました。 もし、「そこは『次へ』で進んだ後に、Stripe側で既存ログインできるよ!」とか「ここにドキュメントあるよ!」という情報をご存知の方がいれば、ぜひコメントなどで教えていただけると助かります! これからMintorを始めるみなさん、どうぞよろしくお願いします。
β版リリースへのお祝い
Mintorさん、β版リリースおめでとうございます。 ここまで形にされたことを尊敬しています。 一点だけ たぶん。 ユーザーの声はすべてを真に受けなくて大丈夫です。 私を含めユーザーになってしまいます。 noteのクリエーターとクリエーターの関係が、 リリースした瞬間から「提供者とユーザー」になってしまいます。 初期は意見が散らばりやすいので、 課題と好みを分けると運営が安定します。 そういう私のお節介なところ 今後も気づいた点をお伝えしてしまうかもしれません。 なので、最終的にはご自身の方向性を大切にしてください。 応援しています。 この記事は数日後に消します。 むみま
svelteいいぞぉ
React全盛の個人開発で、あえてSvelteKitを選ぶ理由 個人開発のフレームワークといえば、いまは React(Next.js) が王道です。 それでも僕は、作ってきたプロダクトを全部 SvelteKit で作っています。 半デジ半農のスキマ時間で開発を続けるなかで、推せる理由は2つ。楽に始められることと、Cloudflareに載せればサーバー代がほぼ$0で済むことです。 ちなみに作ってきたのは、求人アグリゲーター(Chiinavi)、焙煎ログPWA(IriLog)、音声SNS(vovovo)、ブラウザゲーム(薪割りゲーム)。ジャンルはバラバラですが、土台はどれもSvelteKitです。 推しポイント1:とにかく楽に始められる Reactは「画面を描くライブラリ」で、ルーティングも状態管理もサーバー処理も自分で組み合わせます。SvelteKitはそれらを最初から内包した「全部入り」。npx sv create 一発で、書き始められる状態がそろいます。 書き味もHTMLに近く、CSSはコンポーネント単位でスコープが効く。そしてSvelte 5では状態が「ただの変数」で、let count = $state(0) と書いて count++ すれば画面が変わる。Reactの useState / useEffect と依存配列でハマる、あの個人開発あるあるが起きにくい。 書くコードが減ればバグも減る。朝30分でも、思い出しコストが低いからすぐ手が動きます。続けられることが最優先の個人開発で、これは効きます。 推しポイント2:Cloudflareで、サーバー代がほぼ$0 SvelteKitは公式の adapter-cloudflare でWorkers / Pagesにそのまま乗ります。そしてCloudflareの無料枠が、個人開発には過剰なくらい太い(2026年時点)。 Workers:1日10万リクエスト Pages:転送量が実質無制限 D1(SQLite DB):5GB、1日あたり読み取り500万行・書き込み10万行 R2:10GB、しかも転送量(egress)課金ゼロ 僕のプロダクトはホスティングもDBもストレージも全部Cloudflareですが、月の請求は今のところ $0。Next.jsは事実上Vercel前提で、無料枠は商用制限があり規模で課金が跳ねやすい。SvelteKitは最初からCloudflareと素直に噛むぶん、財布にやさしいインフラとそのままつながります。 正直なトレードオフ(Reactが勝つところ) エコシステムはReactが圧倒的。使いたいUIライブラリが見つかりやすい。 AIアシスタントもReactの方が強い。Svelte 5のrunesは新しく、生成コードに古い書き方が混ざることがある。 日本語情報・求人もReactが多い。ただ公式ドキュメントの出来は良いです。 まとめ SvelteKitの価値は「速い・軽い」より、一人で、安く、作り続けられること。一発で始められて、Cloudflareに載せれば毎月$0。ジャンルの違う4プロダクトを同じ道具で回せている事実が、僕にとっての証拠です。Reactで消耗しているなら、次の一作の土台に試してみてください。
Wanko-note(わんこのて)が進化!GIFアニメからも画像を一発抽出可能に!
GIFアニメから「最高の1コマ」を。 Wanko-noteに待望の新機能が追加! note専用の画像最適化ツール「Wanko-note(わんこの手)」がさらに便利になりました。 今回のアップデートでは、「GIFフレーム抽出機能」を新たに搭載しました。 GIFアニメの「ここ!」を逃さない 動画の切り抜きやゲーム画面の録画など、アニメーションGIFの「一番いいシーン」をnoteの見出し画像にしたいと思ったことはありませんか? 新しくなったWanko-noteなら、GIFファイルをドロップするだけで全フレームを瞬時に解析。専用のスライダーを動かすだけで、まるで動画編集ソフトのように自由自在にフレームを選択できます。 抽出からnoteサイズへの変換までをシームレスに フレームを選んだ後は、これまでの機能がそのまま使えます。 1.91:1の黄金比変換: 1920x1006の高解像度で出力。 精密なトリミング: 矢印ボタンと数値入力でミリ単位の調整が可能。 リッチなぼかし背景: 余白を自然なぼかし画像で埋めて、プロのような仕上がりに。 創作の「本質」に集中するために GIFのコマ送りをスクリーンショットで撮り、別のソフトで開き、手動でリサイズし、保存してアップロードする……。そんな面倒な工程はもう必要ありません。 「ドロップ ➔ 選ぶ ➔ コピー ➔ noteに貼り付け」 この驚くほど軽やかな体験を、ぜひ新しいWanko-noteで体験してください。
AIで動くおもちゃの作り方
※ かなえさんアドバイスで記事にしました。 AIでコードは書けるようになりましたが、その先に何をすればよいかがわからない人がいます。 なので、AIで制御できる何かを作ろうと思いましたが、大体高いし、なんか愛情が持てない。 おもちゃなら安いし、これをコントロール出来たら楽しいかと思って、「のんきちゃん」という犬のおもちゃと 有線ラジコンキットとスマホを組み合わせたものを作りました。 これをイベントで展示したら「これ、どうやってつくるんですか?」と何人かに聞かれてそこで「ああ、何かを動かしたい人は私以外にもいる!」ということがわかりました。 そこでこの作り方を教えるワークショップのためのドキュメントを作成中です。 実際のワークショップもやるつもりなので興味がある方は連絡いただければ、次回開催の予定などをお知らせします。 掲載しているURLにはクローラーよけのパスワードをかけているので、ご覧になりたい方はXでDMをいただければ個別にお知らせします。
Stripe決済の練習用プラグインを体験してみる
例えば、何かを開発したくなるとき。 私なら一番不安がある部分に焦点をあてます。 チャンクダウンできないものか?とひたすら壁打ちします。 つまり、小さなテーマを探しては、バラバラに分解します。 そして、もっとも「できそうもない」部分を プロトタイプを作って! とLLMにやってもらいます。 そんなとき、「Stripe決済の練習用プラグインを体験してみる」 これは効き目があり過ぎます。 WordPressのプラグインは、中級者の技術が必要です。 でも、いまならCopilotがあります。 私はLLMが無い時代からやってましたので中身も解ります。 相当丁寧にやってくれます。 どんな言語でも、OKなので「動くやつ」と頼むのです。 WordPressの場合は、PHPです。ちょっとだけ敷居があります。 もっと、簡単なやつかでも、OKです。 大体がPythonでやれます。 まとめ どんな言語、どんなデータベース。 そんな追求は不要です。 なんなら、全部Scratchで作れます。
Blogger向けテンプレート「M-StruX」では配色設定サンプルを用意しています
M-StruXでは基本的なカラー設定をカスタムプロパティで管理しています。 手軽に配色を変更できるように M-StruX公式サイト で配色サンプル+カスタムプロパティのコードを公開しています。 現在 14パターン の配色を用意しており、随時追加していく予定です。 [Sage Denim] [Ash Rose]
Mintor で「Googleログインのままメールアドレスを変える」を実装した — Supabase identity の連携・解除・パスワード移行メモ
Mintor で「Googleログインのままメールアドレスを変える」を実装した — Supabase identity の連携・解除・パスワード移行メモ 自作のコミュニティサービス Mintor(mintor.dev)で、「Googleでログインしている人が、アカウントはそのままにメールアドレスやログイン方法を変えたい」を実装した。Supabase Auth の identity(ログイン手段)まわりは、コードを正しく書いてもダッシュボードの設定や仕様で挙動が変わり、何度もハマった。本記事はその回避策の記録である。 結論(先に) メアド変更の確認リンクは、OAuthログイン用の auth/callback(?code を PKCE 交換する経路)では処理できない。verifyOtp の専用ルートを作る。 パスワードの設定/変更は「Secure password change」が有効だと、reauthenticate() のコード確認フローが必須になる。updateUser({ password }) の直接呼び出しは弾かれる。 Google 連携の解除/追加は unlinkIdentity / linkIdentity(ダッシュボードで Manual linking を有効化)。解除は パスワード検証でロックアウトを防ぐ。 連携状態は getUserIdentities() で判定する。app_metadata.provider は unlink しても古いまま残る。 「パスワードが設定済みか」はクライアントに公開されない。SECURITY DEFINER の RPC で boolean だけ返す。 課題: 「Googleの人のメアドを変える」は何を変えることか Mintor は Google ログインとメール/パスワードの両方に対応している。Google ユーザーの「メアドを変えたい」は、技術的には auth.users.email を変えることになる。ログインは Google の identity(sub)でマッチしていてメアドに依存しないので、メアドを変えても同じアカウントにログインでき続ける。通知メールの宛先も auth.users.email を使っているので、メアド変更がそのまま「連絡先変更」として機能する。 ここまでは素直だった。ハマったのは確認リンクの着地だった。 メアド変更の確認リンクは verifyOtp 専用ルートで処理する 最初は supabase.auth.updateUser({ email }) の確認リンクを、既存の OAuth 用 auth/callback に戻す設計にした。callback は ?code を exchangeCodeForSession で交換する PKCE 経路だ。 本番で確認リンクを開くと、2通の確認メール(Secure email change が有効で新旧両方に届く)がそれぞれ別の挙動になった。 新アドレス側: #message=... のハッシュで戻り、?code が無い → no_code でログイン画面へ 旧アドレス側: ?code はあるが、メールを開いた文脈に code_verifier が無く exchangeCodeForSession が session_error どちらもログイン画面に着地し、メアドも変わらなかった。原因は「OAuthログイン用の経路を、メアド確認に流用したこと」だった。 解決は、確認専用のルートを作って verifyOtp で token_hash を直接検証することだった。PKCE のコード交換に依存しないので、メールを別の文脈で開いても通る。 そのうえで、メールテンプレートのリンクを {{ .ConfirmationURL }} から差し替える。 これで本番で、メアド変更がユーザー画面と Supabase の両方に反映されることを確認できた。 パスワード設定は reauthenticate のコード確認が要る Google ユーザーがメール/パスワードでもログインできるよう「パスワードを追加設定」できるようにした。最初は updateUser({ password }) を直接呼んだが、本番で Password update requires reauthentication で失敗した。Supabase の「Secure password change」が有効だと、パスワード設定の前に本人確認(再認証)が要る。 解決は2ステップにした。 Reauthentication のメールテンプレートに {{ .Token }}(6桁コード)が入っている必要がある。デフォルトテンプレートには入っているので、最低限はそのまま動く。 なお、フォーム側のパスワード検証も Supabase のポリシーに揃える必要があった。記号必須なのにフォームが「8文字・大文字・小文字・数字」しか見ておらず、フォームを通ったパスワードがサーバーで弾かれて生の英語エラーが出ていた。検証ルールはサーバーのポリシーと一致させる。 Google解除はパスワード検証でロックアウトを防ぐ 「Googleをやめてメール+パスワードだけにする」を unlinkIdentity で実装した。前提は2つ。ダッシュボードで Manual linking を有効化(Authentication → Sign In/Providers → Allow manual linking)、そして identity が2つ以上(最後の1つは外せない)。 最初のゲートは「email identity があるか」で出し分けたが、これは不十分だった。メアド変更で email identity ができた Google アカウントは、パスワード未設定でも解除ボタンが出てしまい、外すとログインできなくなる。 解決は、解除前に パスワードを入力させて signInWithPassword で検証 してから unlink することだった。 検証成功は「メール+パスワードで確実に入れる」ことを保証する。signInWithPassword で再認証されるので、解除後もセッションは維持される。 連携状態は getUserIdentities で判定する(app_metadata.provider は古い) 解除後、設定画面のカードが「Googleで管理」のまま変わらなかった。原因は、カードの出し分けを app_metadata.provider(解除後も 'google' のまま)で見ていたこと。Supabase は identity を unlink しても app_metadata.provider を更新しない。 これで解除した瞬間にカードが正しく切り替わる(リロード不要)。 Google連携は prompt=select_account でアカウントを選ばせる 解除の対として「メール+パスワードのアカウントに Google を後付け連携」も実装した。linkIdentity を使う。 prompt: 'select_account' が肝だった。これが無いと、ブラウザでログイン中の Google に自動で連携してしまい「どのアカウントにするか選べない」。付けると毎回アカウント選択画面が出る。 パスワード有無は SECURITY DEFINER の RPC で返す 「パスワード: 設定済み/未設定」を出したかったが、Supabase はクライアントに「パスワード有無」を公開しない。getUserIdentities の email identity 有無は、メアド確認由来でも付くので確実な判定にならない。 解決は、本人の boolean だけ返す関数を作って RPC で呼ぶことだった。 auth.uid() の行だけを見て boolean を返す。パスワードの中身(ハッシュ)も他人の情報も出ない。search_path='' でフルパス参照にし、authenticated に限定する。 型の順番がポイントで、ダッシュボードで関数を作る → npm run db:types で型再生成 → コードで supabase.rpc('current_user_has_password') を呼ぶ、の順なら型キャストなしで通る。 まとめ(次に同じ場面に出会う人へ) Supabase の identity 操作は、ダッシュボード設定(Manual linking / Secure password change / Secure email change)とセットで挙動が変わる。コードだけ見ても再現しない。設定を確認する。 「本番でしか分からない着地挙動」(確認リンクが ?code で戻るか、ハッシュで戻るか)は本番で検証する。ローカルが不安定なら尚更、安定したビルドで見る方が速い。 ロックアウトしうる操作(連携解除)は、「できる条件を満たしているか」をサーバー側 or 実際の認証で担保する。表示用の推測で判断しない。 クライアントに公開されない情報(パスワード有無)は、SECURITY DEFINER の小さな RPC で「本人の boolean だけ」返すと安全に取れる。 参考: Supabase Auth の Identity Linking、verifyOtp、Reauthentication のドキュメント。
今あえてBloggerを選ぶ理由とは? — 非エンジニアでもここまでできる、Bloggerという選択肢
WordPressでもはてなでもない。Googleが2003年から運営し続ける"最古"のブログサービス、Blogger。日本国内での利用者は決して多くないが、エンジニアの視点から見ると、あえて選ぶ理由が実はいくつもある。 本記事では、Bloggerを実際に自作テンプレートで運用している立場から、メリット・デメリットを客観的に整理する。さらに、Bloggerでは珍しいSPA構成・外部スクリプト非依存・動的ページレンダリングという設計思想についても紹介する。 --- 主要ブログサービスとの比較 | 項目 | Blogger | WordPress(セルフホスト) | はてなブログ | note | | ------------ | -------- | ----------------- | ---------- | ---- | | 月額費用 | 無料 | サーバー代 500〜2,000円〜 | 無料 / 600円〜 | 無料あり | | 独自ドメイン | 無料枠で可 | 別途取得が必要 | 有料プランのみ | 非対応 | | AdSense申請 | 独自ドメイン不要 | 独自ドメイン推奨 | 独自ドメイン推奨 | 非対応 | | テンプレートカスタマイズ | XMLを完全制御 | PHPテーマを完全制御 | デザインCSS | 不可 | | サーバー管理 | 不要 | 必要 | 不要 | 不要 | | セキュリティ管理 | 不要 | プラグイン更新等が必要 | 不要 | 不要 | | コミュニティ | ほぼなし | エコシステムが充実 | 国内充実 | 充実 | WordPress(セルフホスト)と比べると、カスタマイズ自由度は同等でありながら、維持コストとサーバー管理の手間がゼロという点がBloggerの最大の差別化ポイントになる。 --- Bloggerのいいところ 完全無料で広告なし 利用料がかからないのはもちろん、サービス側が強制的に広告を挿入してくることもない。AdSenseを導入すれば、収益はすべて自分のものになる。 サーバー管理が不要 レンタルサーバーの契約・更新・バックアップ・WordPress本体やプラグインのアップデート、こういった維持管理作業が一切発生しない。記事を書くことだけに集中できる。 独自ドメインなしでもAdSense申請できる 多くのブログサービスでは独自ドメインが事実上必須だが、Bloggerは blogspot.com サブドメインのままでもAdSense審査に通る実績がある。もちろん独自ドメインの設定も無料で可能。 Google製サービスとの親和性が高い Analytics、Search Console、AdSense、Google Photosとシームレスに連携できる。特にGoogle Photosの画像ホスティングは、URLパラメータだけでリサイズ・WebP変換が可能な実質CDNとして活用できる。 カスタマイズ範囲が広い XMLテンプレートを直接編集でき、HTML/CSS/JavaScript を自由に記述できる。テーマ構造の制約を受けるWordPressテーマと違い、ページの構造から描画ロジックまで完全にコントロールできる。 プラグイン依存ゼロ WordPressはプラグインの更新漏れがセキュリティリスクに直結するが、Bloggerにはそもそもプラグインという概念がない。自分が書いたコードだけが動く、という安心感がある。 --- Bloggerのよくないところ 日本国内の情報が極端に少ない 国内利用者が少ないため、日本語の有益な記事がほとんど見つからない。英語の公式ドキュメントとStack Overflowが主な情報源になる。 コミュニティがない はてなブログのような読者登録・ブックマーク文化、noteのようなフォロワー経由の流入は期待できない。流入はほぼSEOとSNS経由になる。 XMLベース構造の学習コストが高い テンプレートはXMLで記述され、Blogger固有のウィジェットタグ・条件分岐・データタグが存在する。この仕様を理解しないままカスタマイズしようとすると、意図しない挙動に悩まされる。 CSS/JS/HTMLがテンプレート1ファイルに集約される 構造・スタイル・スクリプトがすべて1つのXMLファイルに収まるため、規模が大きくなると冗長になりがちだ。ただし逆に言えば、外部依存ゼロの自己完結型テンプレートとして設計できるという利点でもある。 エディタのリッチテキストがHTMLを汚染しやすい 投稿エディタを「作成」モードで使うと、不要なspanタグやstyleが挿入されてHTMLが汚れる。HTMLモードで直接記述する習慣が必要になる。 --- 自作テンプレートの設計思想 — Bloggerの限界を破る ここからは、実際に運用している自作テンプレートの設計について紹介する。 このテンプレートの核となる思想は 「外部ライブラリに依存しない、完全自作のSPA構成」 だ。通常のBloggerテンプレートはページ遷移のたびにフルリロードが発生するが、History APIとBlogger JSON Feedを組み合わせることでDOM差分更新による動的ページレンダリングを実現している。 アーキテクチャの概要 | 項目 | 採用方針 | | ------- | -------------------------------- | | ページ遷移 | SPA(History API + DOM更新) | | 外部ライブラリ | 不使用(jQuery等に非依存) | | データ取得 | Blogger JSON Feed API | | 画像配信 | GoogleホストのURLパラメータ書き換えで最適化 | | アクセス解析 | Google Apps Script (GAS) に独自ログ送信 | 実装例① インフィード関連記事 外部ライブラリを一切使わず、Blogger標準のJSON-in-script feedからランダム関連記事を動的挿入している。 実装例② アクセスログをGASに送信して独自スコアリング 滞在時間とスクロール率を掛け合わせた独自のエンゲージメントスコアを算出し、Google Apps Script経由でSpreadsheetに蓄積している。外部アクセス解析ツール不要でページ価値を定量評価できる。 実装例③ 画像の自動最適化 Bloggerの画像URLパターンを正規表現で書き換えて、表示コンテキストに応じたサイズ配信を実現。WebP変換もURLパラメータ一つで対応できる。 --- こんな人に向いている Bloggerが合う人 HTML/CSS/JSの基礎知識があり、テンプレートを自作したいエンジニア 維持コストゼロで長期運用したい個人ブロガー AdSenseでの収益化をすぐに始めたい人 Google Workspaceエコシステムに統合したい人 プラグイン依存のないシンプルな構成を好む人 Bloggerが合わない人 読者コミュニティ形成を重視する人(はてな・noteが適切) プラグインで機能を拡張したい人(WordPressが適切) GUIだけでカスタマイズを完結させたい人 --- まとめ Bloggerは「枯れたプラットフォーム」だ。しかしそれは欠点ではなく、仕様が安定していて予測可能であることを意味する。XMLの学習コストを払い切れば、コストゼロで自由度の高いブログ基盤として十分機能する。 WordPressのセルフホストと比べると、カスタマイズの自由度は同等でありながら、サーバー管理・セキュリティ対応・維持費という三つの負担がまるごとなくなる。 「日本語情報が少ない」という欠点すら、英語ドキュメントとソースコードを読む習慣があるエンジニアにとっては差別化のチャンスになる。コストを抑えつつ技術的に攻めたい個人エンジニアにとって、Bloggerは2026年現在も十分に有力な選択肢だ。
Next.js 15 + Firebase Auth + Gemini Live API を Vercel にデプロイ — 環境変数の改行で30分ハマった話
Next.js 15 + Firebase Auth + Gemini Live API を Vercel にデプロイ — 環境変数の改行で30分ハマった話 概要 Next.js 15(App Router)+ Firebase Auth + Gemini Live API(WebSocket)で作ったAI英会話PWAを、Vercelにデプロイした。所要時間は約30分。環境変数の末尾改行でログインが失敗する落とし穴があったので、再現可能な手順としてまとめる。 構成 Next.js 15(App Router, Turbopack) Firebase Auth(Googleログイン)+ Firestore Firebase Admin SDK(サーバーサイドトークン検証) Gemini Live API(WebSocket音声会話) なぜ Vercel か 当初は Firebase Hosting + Cloud Functions を想定していたが、以下の理由で Vercel に変更した。 Next.js の SSR / API Routes がゼロ設定で動く Cloud Functions の Next.js 対応は設定の手間が大きい 今日中にデプロイを終わらせる必要があった 将来的に Firebase App Hosting に移行する余地は残しつつ、まずは Vercel で動かす。 手順 Vercel CLI のインストール プロジェクトリンク GitHub リポジトリとの連携も自動で行われる。 環境変数の登録(⚠️ 落とし穴) 今回ハマったポイント。以下は失敗例。 echo はデフォルトで末尾に改行を付けるため、環境変数の値末尾に \n が混入する。 ブラウザのコンソールに以下のエラーが出て初めて気づいた。 URL 内の %0A が改行のエンコード。Firebase の Auth ドメインとAPIキーに改行が紛れ込み、iframeの生成に失敗していた。 正解: マルチライン環境変数(Firebase Admin の Private Key) Firebase Admin SDK の FIREBASE_PRIVATE_KEY は \n エスケープを含む長い文字列。ファイル経由で登録する。 デプロイ 初回ビルドは53秒で完了。 Firebase の承認済みドメイン追加(必須) Vercel の本番URLを Firebase Console の Authentication → Settings → Authorized domains に追加しないと、Googleログインが失敗する。 追加したもの: your-app.vercel.app(安定エイリアス) 各デプロイごとに発行される一時URL(your-app-{hash}-{org}.vercel.app)は追加しなくてよい。安定エイリアス経由でのみアクセスさせる。 WebSocket(Gemini Live API)は動くか Gemini Live API はブラウザから直接 WebSocket で接続する構成のため、Vercel のサーバー側での WebSocket サポートは不要。API Routes はセッショントークン発行のみを担当する。 この構成なら Vercel の serverless 環境でも問題なく動作する。 環境変数チェックリスト デプロイ前に以下を Vercel に登録したか確認する。 まとめ echo ではなく printf を使う(末尾改行を避ける) Firebase の承認済みドメインに Vercel URL を忘れず追加する Gemini Live API は クライアント直接 WebSocket なのでVercelで問題なし マルチライン環境変数はファイル経由で vercel env add NAME prod < file 環境変数の %0A エラーは地味だが、気づきにくい。同じ構成を試す人の参考になれば。
Substack のチャット返信を傍受して「自分の返信」を自動取得する — quote_id で紐づける
Substack のチャット返信を傍受して「自分の返信」を自動取得する — quote_id で紐づける 自作 Chrome 拡張 Substack Comment Keeper(SCK) で、チャット(コミュニティ)における自分の返信を自動で記録できるようにした。Substack の通知ページ(/activity)には「自分の返信本文」が出ないため、チャットではこれまで手動入力しかなかった。実機で API を調べ、quote_id を鍵に既存の突合ロジックへ繋いだ話。 結論(先に) チャットの読み込み/投稿は /api/v1/community/...comments で、応答の各返信に quote_id(返信先コメントの UUID) が入る SCK は元々この通信を傍受している(MAIN world で fetch を monkey-patch)。処理を足すだけで自分の返信を拾える 自分の返信の quote_id → 引用先(相手)のコメントを同じ応答から取得 → 既存の「ハンドル+本文スニペット」突合で該当カードに myReplyBody を反映 content script や /activity の DOM には一切手を入れていない 課題:通知ページには「自分の返信」が出ない SCK は /activity の通知から「誰が返信/いいね/リスタックしたか」のカードを作る。だが通知データで取れるのは「そのコメントの親(上の階層)」までで、「自分がそれに返した返信(下の階層)」は含まれない。だから /activity 経由では自分の返信本文を自動取得できなかった。 調べたこと(DevTools) チャットスレッドを開くと、ポーリングで全履歴が読み込まれる。応答の replies[] を観察すると、各返信はこの形だった(抜粋)。 parent_id はスレッド根で全返信共通=紐づけに使えない quote_id が「返信ボタンで返した相手コメント」の UUID=個別の紐づけに使える 投稿時の POST 本体にも quote_id が入る(実機確認) 実装 通信を傍受している interceptor に、community 応答のパーサを追加。quote 付き返信から必要な情報だけ取り出して background へ送る。 background 側では、自分の返信だけ採用し、引用相手を既存の突合関数で特定して反映する。 findRecordKey(handle, body) は元々「reader 系コメント」を既存カードに突合するために作ってあった関数。チャットでもそのまま流用できた。 判断理由 なぜ content.js / DOM を触らないか:紐づけに必要な情報(相手の本文・ハンドル)が community 応答に全部入っていたため。DOM から UUID を拾う実装を足すより、既存の傍受経路に乗せる方が壊しにくい なぜ quote_id か:parent_id はスレッド根で一定。個別の返信先を一意に指すのは quote_id だけ 誤紐づけを避ける:quote_id が無い返信(まとめ送信)は対象外。時刻などで推測すると別カードに入る事故が起きるため、「確実なものだけ」に絞った 制約 「返信ボタンで個別に返した分」のみ自動取得。まとめ送信は対象外(手動運用+一括「対応した」で補完予定) スレッドを開いた分だけ反映(履歴がそのとき読み込まれる)。逆に言えば、過去の返信もスレッドを開けば後追いで埋まる 教訓 既に傍受している通信は、新しい endpoint を足すだけで活用できることがある。DOM をいじる前に「その情報、もう通り道に流れてないか?」を疑う 紐づけは「確実な鍵があるものだけ」。無い場合は無理に推測せず、正直に拾わない方が信頼できる (同拡張の関連実装:通知のコメントを既存カードへ突合する findRecordKey、リスタック検知の interceptor パターン)
CSS の `[hidden]` 属性が display 指定で上書きされる罠 — フィルター結果に前のカードが残った話
CSS の [hidden] 属性が display 指定で上書きされる罠 — フィルター結果に前のカードが残った話 自作 Chrome 拡張 Substack Comment Keeper(Substack の通知画面のコメントを管理する拡張)で、コメント一覧のフィルターが「効いていないように見える」バグを直した。真因は JavaScript のフィルターロジックではなく、CSS で [hidden] 属性が無効化されていたことだった。再現と切り分けの手順をまとめる。 結論(先に) <div hidden> で隠したつもりの要素に CSS で display: flex などを当てていると、[hidden] が効かず要素が消えない。 ブラウザ標準の [hidden] { display: none } は、author CSS の display 指定と特異度が同じ(どちらも (0,1,0))なので、後に書いた author 側に負ける。 対策は [hidden] { display: none !important; } を 1 つ置いて全体で効かせる。 あわせて、一覧を再描画する関数が「空のとき中身をクリアしない」と、隠したはずの古い要素が DOM に残り続ける。クリアも入れる。 症状 ダッシュボードで「やり取り相手=A さん」で絞り込み、さらに「メモ付きのみ」を ON にすると、本来出ないはずの別人 B さんのカードが表示される。相手フィルターと他フィルターが AND ではなく OR になっているように見えた。 切り分け 1: フィルターロジックは正しかった まずフィルター関数を疑った。が、コードは相手 → 種別 → メモ → 優先の順に .filter() を重ねる素直な AND で、論理的に B さんは除外される。 実データで actor = "@aaa"(A さん)+ hasMemo = true をシミュレートしても、結果は A さんのレコードだけ。B さんは出ない。ロジックは正しかった。 切り分け 2: state も正しかった ブラウザの DevTools コンソールで、現在のフィルター状態を直接読んだ。 表示中のドロップダウンと state は一致していた。「いつのまにか全員に戻っていた」わけでもなかった。 切り分け 3: DOM に「残骸」が残っていた 各タブのリスト要素の状態をコンソールで一覧した。 hidden プロパティは true(属性は正しくセットされている)。なのにカードが残っていて、実際の画面では見えていた。プロパティの値と見た目がズレている。 真因: .groups-container { display: flex } が [hidden] を上書き 一覧の器はこうなっていた。 [hidden] がデフォルトで効くのは、UA スタイルシートに [hidden] { display: none } があるおかげ。だが author CSS で .groups-container { display: flex } を指定すると、特異度がどちらも (0,1,0) で同じ。同特異度なら後から読まれた author 側が勝つ。結果、hidden = true にしても display: flex のまま表示され続ける。 コンソールの hidden: true と「見た目は表示」のズレはこれだった。 実はこのコードには .tab-panel[hidden] や .cs-panel[hidden] のような個別の打ち消しが 6 箇所あった。この罠を知っていて個別に潰していたのに、.groups-container とリアクション系に当て忘れていた。個別対応は当て忘れが再発する。 真因 2: 空のとき innerHTML をクリアしていなかった 一覧の再描画関数は、items が空のとき早期 return していて innerHTML をクリアしていなかった。 フィルターで結果が 0 件になると、前回描画したカードが DOM に残る。[hidden] が効いていればただ隠れるだけだが、上の CSS バグで隠れないので「残骸」として見えてしまう。inbox に 9 枚溜まっていたのはこれ。 修正 2 箇所。 CSS 側は root に [hidden] を 1 つ置くことで、当て忘れていた .groups-container もリアクション系も、将来追加する要素も一括で効く。JS 側は残骸を物理的に残さないし、DOM が無限に肥大するのも防げる。 比較: 個別 [hidden] か、グローバル [hidden] か | 方針 | メリット | デメリット | |---|---|---| | .foo[hidden] を要素ごとに | 影響範囲が狭い | 当て忘れが再発する(今回がそれ) | | [hidden] { display:none !important } 1 つ | 当て忘れが構造的に起きない | !important を 1 つ使う | !important は乱用すると辛いが、「hidden な要素は常に消える」はセマンティクスとして正しい。ここは 1 つ置く価値があると判断した。 教訓 display を当てた要素を hidden 属性で隠すときは、[hidden] が効くか確認する。効かないなら [hidden]{display:none} の打ち消しを忘れない。 一覧の再描画は「空のときも必ずクリア」。隠すだけだと、別のバグと組み合わさったとき残骸として表面化する。 コンソールの hidden:true(属性)と「見た目」はズレることがある。プロパティの値だけ見て満足せず、実際の表示と children を両方見る。 参考 HTML hidden 属性 (MDN): https://developer.mozilla.org/ja/docs/Web/HTML/Global_attributes/hidden display と [hidden] の優先順位 (CSS specificity): https://developer.mozilla.org/ja/docs/Web/CSS/Specificity
Stripe Connect の v1/v2 webhook 混在対応 — parseThinEvent と「届いてるのに400」の罠(Mintor)
Stripe Connect の v1/v2 webhook 混在対応 — parseThinEvent と「届いてるのに400」の罠(Mintor) 個人開発のコミュニティアプリ Mintor で、Stripe Connect の onboarding 完了が DB に反映されないバグを直した記録。「Stripe 側は完了しているのにアプリ側がずっと pending」という症状で、真因は Stripe の v2 イベント(thin events)を旧来の署名検証メソッドで弾いていたことだった。 結論(先に) API バージョンを上げると、Connect アカウントの状態変化は v2 イベント(v2.core.account[...])で飛ぶようになる v1 イベントは stripe.webhooks.constructEvent() で検証するが、v2 thin events は stripe.parseThinEvent() で検証する。混同すると署名検証で 400 になる 1 つの URL で v1/v2 両方を受けるなら、両方の検証を順に試す 拾うべきイベントは .updated だけでなく .capability_status_updated も必要(charges/payouts の有効化はこちらで飛ぶ) 課題 Mintor では収益受け取り側が Stripe Connect(Express)でつながる。account.create 時の API バージョンは 2025-08-27.basil。 onboarding が完了すると、Stripe から webhook が飛んで、profiles の stripe_account_status を complete に更新する設計。ところが本人 dogfood で、Stripe 側は charges_enabled / payouts_enabled / details_submitted がすべて true なのに、アプリ側は pending のまま動かなかった。 試した順番 webhook が届いているか確認 Stripe Dashboard の「イベント」タブで対象アカウントを絞ると、状態変化イベントは発火していた。ただしイベント名が見慣れないものだった: 旧来の account.updated(v1)ではなく、v2.core.account[...](v2)。API バージョンを上げた結果、Connect アカウントのイベントが v2 体系で飛んでいた。 handler に v2 のイベント名を追加 → それでも 400 最初は「listen するイベント名を v2 のものに足せばいい」と考えて、SUPPORTED_EVENTS に追加した。しかし webhook の配信ログを見ると、エンドポイントには届いているのに すべて 400 ERR だった。 レスポンス本文: 署名検証で落ちていた。 v2 thin events は検証メソッドが違う ここが真因。Stripe の v2 イベントは "thin event" という形式で、署名検証に専用メソッドが必要*だった。 | | v1 イベント | v2 thin events | |---|---|---| | 例 | account.updated | v2.core.account[...].updated | | 検証 | stripe.webhooks.constructEvent() | stripe.parseThinEvent() | | payload | data.object に全情報 | related_object.id のみ(薄い) | constructEvent() に v2 thin event を渡すと署名検証で例外 → 400。SDK は stripe@18.5.0 で、parseThinEvent は提供済みだった。 解決策 署名検証を v1/v2 で順に試す 1 つの URL(/api/stripe/webhook)で、Dashboard 上は別々の 2 エンドポイント(v1 用 / v2 用、それぞれ別の signing secret)からのリクエストを受ける。設定されている secret を順に試す: v2 イベントは retrieve で v1 形式に正規化して再利用 v2 thin event は related_object.id(アカウント ID)しか持たない。詳細は API で取り直し、既存の v1 用 handler に渡してロジックを使い回す: 拾うイベントは capability_status_updated も含める charges_enabled / payouts_enabled の有効化は .updated ではなく *.capability_status_updated で飛んでくる。.updated だけだと取りこぼす: ハマった脇道(記録として) scope の誤解 「v2 イベントは連結アカウント scope に飛ぶから endpoint の scope ミスだ」と疑った。が、実際は お客様のアカウント scope で正しく届いていた(400 だったので「届いていない」と誤認しかけた)。配信ログのステータス(届いてないのか / 届いて 400 なのか)を先に確認すべきだった。 Cloud Run の「完了」ボタン 何度直しても 400。真因は signing secret が本番に反映されていなかったこと。Cloud Run コンソールで値を入力して「デプロイ」は押していたが、シークレット欄の 「完了」ボタンを押していなかったため確定されていなかった。 前日に直したつもりの v1 secret も同じ理由で未反映だった。前日の記事では再送が 200 OK になり「復旧完了」と書いたが、それは signing secret ローテーションの 猶予期間(旧 secret も一定時間は有効で、Stripe が新旧両方の署名を Stripe-Signature に含める)中に再送したため、旧 secret 側の署名で検証が通っただけだった。新 secret は Cloud Run に未反映のままで、猶予期間が切れて Stripe が新 secret のみで署名するようになった後、再び 400 に戻っていた。 検証 修正をデプロイ後、Dashboard で過去に 400 になっていたイベントを「再送」すると 200 OK。profiles が手動 SQL なしで complete に自動更新され、更新時刻が再送時刻と一致した。今後の新規ユーザーはリアルタイムで自動反映される(再送は修正前に失敗したイベントの救済時のみ必要)。 教訓 次に同じ場面に出会う人へ: API バージョンを上げたら webhook イベントの形式(と検証メソッド)が変わっていないか確認する。Connect は v1 account.updated → v2 v2.core.account[...] へ移行している v2 thin events は parseThinEvent。constructEvent のままだと黙って 400 になる webhook が「動かない」時は、まず配信ログのステータスを見る**(届いてない / 4xx / 5xx で原因が三分される) マネージドサービスの設定 UI は「保存」と「確定」が分かれていることがある。反映されない時は env が本当に入っているか実値で確認する 参考 Stripe: Thin events と parseThinEvent(公式ドキュメント) 前日の関連記録: signing secret 不一致で webhook が 12 日間失敗していた件(別記事)
Chrome 拡張で actor name を innerText 正規表現から DOM リンク textContent に切り替えた話
Chrome 拡張で actor name を innerText 正規表現から DOM リンク textContent に切り替えた話 Substack Comment Keeper という Chrome 拡張で、通知画面 (/activity) の actor (誰がいいねした / 返信した) の表示名を取り込む処理にバグがあったので直した。元の実装と、なぜ切り替えたかをまとめる。 元の実装 (innerText 正規表現) content.js で notification entry の innerText から正規表現で名前を抽出していた。 {actor 名} replied to your の {actor 名} 部分を取る、というアプローチ。 バグ 1: liked your がパターンに無い これは単純なミスで、replied to your restacked your だけ書いていて liked your が抜けていた。like 通知では nameMatch が常に false になり、authorName が authorHandle と同じになる。 ダッシュボードの「いつもいいねしてくれる人」ランキングで、いいねくれた人だけハンドル (@xxx) しか表示されない現象になっていた。liked your をパターンに追加することで like も name 取れるように。 バグ 2: 一部の通知で先頭が name じゃない 文言修正だけだと、まだ取れないケースがあった。例えば commented on your post 形式、または通知エントリの構造によって innerText の先頭に時刻やバッジ文言が入る場合があり、正規表現マッチが失敗する。 そこで、文言パターンマッチ自体をやめて、DOM 構造から直接 actor の表示名を取る方式に切り替えた。 切り替え後: actor link の textContent 実機の /activity で DOM をログ出力して構造を確認した。Substack の通知 entry には、actor のプロフィールリンクが 2 つある: document.querySelector('a[href*="/@"]') だと最初の (アバター) リンクが返るので、textContent は空。 querySelectorAll で全部取って、handle と一致しない非空テキストを採用する方式に切替。 ポイント: handle に一致するリンクだけを対象にする (他の actor リンクと区別) textContent が空ならスキップ (= アバター画像リンク) textContent が handle と同じならスキップ (それは表示名じゃない) 最初に見つけた非空・非 handle のテキストを採用 正規表現マッチは fallback として残してある (DOM 構造が変わった時のため)。 既存データの補修 content.js の保存処理で、新データが既存 storage を上書きする: 修正後の content.js で /activity を再スクロールすると、表示されたエントリの actor name が新ロジックで取り直され、storage に書き戻される。 ただし Substack の /activity は仮想スクロールで、画面外の entry は DOM に存在しない。古い通知 (何ヶ月前) は再スクロールしても辿り着けず、補修できない。古い record は次にその actor が新しいいいね / 返信 / リスタックを送ってきた時に更新される。 学び innerText パターンマッチは表面的なテキスト全体に依存するので脆い (改行 / 周辺テキスト / バッジ等が混入する) DOM 構造ベースの取得 (querySelector + 属性 + textContent) の方が安定 1 entry に複数 actor が含まれるパターン (連名通知) は別 Issue で対応中 スクレイピング系の処理は「実機ログを取って確認」が必須。仮定で書くと取れてるつもりで取れてない DOM スクレイピングは「正規表現でテキスト全体を切り出す」より「DOM 階層を辿る」方が後で楽になる、という典型例だった。
Chrome ウェブストア「サポートのリンクにアクセスできません」エラーの切り分け — petapeta 申請時のタイポ罠
Chrome ウェブストア「サポートのリンクにアクセスできません」エラーの切り分け — petapeta 申請時のタイポ罠 自作のブックマーク管理アプリ petapeta の Chrome 拡張を、Chrome ウェブストアに限定公開で提出した。アップロード自体は前日に通過していたが、Developer Dashboard の「公開」ボタンで赤いエラーが出て止まった。本記事はその切り分け手順を残す。 結論(先に) | 順位 | 原因候補 | 切り分け方 | |---|---|---| | 1 | URL のタイポ・末尾空白・全角混入 | 入力欄の URL をコピーして、自分でブラウザに貼って 200 OK か確認 | | 2 | http:// で入れている | https:// 必須 | | 3 | Gist HTML URL が稀に弾かれる | Raw URL or 自分のホスティングに切り替え | | 4 | 公式 URL ではないドメイン | 関係なし(サポート URL は所有権認証 不要) | 最頻発はタイポ。一番疑いやすい「ドメインの所有権認証」「Gist が拒否された」は実は最後に検討すべき。 環境 Chrome ウェブストア Developer Dashboard サポート URL に GitHub Gist を指定(GSM 時代から運用) プライバシーポリシー URL も別の Gist を指定 症状 公開ボタンを押すと、赤帯で次の文言が出る。 ZIP アップロード・スクショ・プロモタイル・プライバシーポリシー URL は全て通過済み。詰まっているのはサポート URL だけ。 試した順番(失敗を含む) ドメイン所有権認証を疑う 最初に「Chrome ウェブストアが GitHub Gist の所有権を要求しているのでは」と仮定した。Search Console で gist.github.com の認証は当然できない(他人のドメイン)ので、petapeta-space.web.app 配下に /support ページを作って Firebase Hosting に乗せる案を検討。 しかし公式ドキュメントを読むと、所有権認証が必須なのは「Official URL」フィールドだけで、サポート URL は不要と明記されていた。 Domain verification is required only for the "Official URL" field, which must come from sites verified in Google Search Console. The Support URL lacks this requirement. ということは別の原因。 Gist HTML URL が拒否される説 過去に「Chrome ウェブストアのサポート URL に Google Forms を直接入れると検証エラーが出る」事例を踏んでいたので、Gist HTML も同じ可能性があると考えた。Raw URL(https://gist.githubusercontent.com/.../raw/...)に差し替える案を準備。 しかし WebFetch で Gist HTML / Raw 両方とも 200 OK で公開アクセス可能であることを確認。サーバー側の問題ではない。 入力 URL を見直す(当たり) ここで原点に戻り、ユーザー(自分)に Dashboard の入力欄に貼られている URL を実物で確認してもらった。すると URL の一部がタイポ していた。Gist URL は 32 桁のハッシュなので、コピペし損ねた1文字でリンクが死ぬ。 タイポを直して再保存 → 公開 → 申請通過。 教訓 切り分け順は「素朴な原因」から 「エラーメッセージが抽象的だと深刻な原因を疑いがち」だが、Chrome ウェブストアの URL 検証エラーは タイポ・空白混入が最頻発。先に「自分でその URL を踏んで 200 OK か」を確認する。 公式ドキュメントを読み込む前に、入力欄のテキストをコピーしてブラウザに貼る。1秒で切り分く。 提出時 URL チェックリスト [ ] サポート URL を自分でブラウザに貼って 200 OK で開ける [ ] プライバシーポリシー URL も同じく確認 [ ] 末尾に空白・改行が無い(コピペ時に混入しがち) [ ] https:// で始まる [ ] 公開しているメタファイル(STORE_LISTING.md など)の URL 一覧と入力欄が一致 公式仕様と実態の整理 | 項目 | 仕様 | 実態 | |---|---|---| | サポート URL の所有権認証 | 不要 | 不要(Gist でも通る) | | HTTPS | 必須 | 必須 | | ドメイン制限 | なし | 拒否事例は稀。先にタイポを疑うべき | 審査期間データ(参考) 過去に提出した拡張機能の審査期間。 | 拡張機能 | 申請日 | 公開日 | 期間 | |---|---|---|---| | Save & Theme for Google Slides(旧版) | — | — | 4 日 | | Substack Comment Keeper | 2026-05-24 | 2026-05-25 | 1 日 | | petapeta v1.6.0 | 2026-05-28 | 審査中 | — | 提出後 1〜4 日の幅。これより長くかかる場合は申請内容に何か引っかかっている可能性があるので問い合わせる。 まとめ 「サポートのリンクにアクセスできません」エラーが出たら、まず入力欄の URL を自分でブラウザに貼って踏んでみる。これだけでほぼ解決する。所有権認証や Gist 拒否を疑うのはその後でいい。
petapeta を Chrome ウェブストアに提出する時に踏んだ 2 つの罠 — manifest の key とアイコンサイズ
petapeta を Chrome ウェブストアに提出する時に踏んだ 2 つの罠 — manifest の key とアイコンサイズ 自作のブックマーク管理アプリ petapeta の Chrome 拡張を、Chrome ウェブストアに限定公開で提出した。実装は終わっていたが、アップロード段階で 2 回弾かれた。本記事はその原因と対処を残す。 結論(先に) | 罠 | 症状 | 対処 | |---|---|---| | 1. manifest.json に key フィールド | 「マニフェストでは key フィールドを使用できません」エラー | zip 化スクリプトで動的に除外 (ローカル manifest は残す) | | 2. アイコンファイル名と実サイズの不一致 | アップロード時に弾かれる | 単一ソースから 16 / 48 / 128 を生成し直す | 副作用として zip サイズが 7.5 MB → 471 KB に縮んだ。 環境 Chrome 拡張 (Manifest V3) 開発: 複数 PC (デスクトップ + ノート) で unpacked load zip 作成: Python zipfile (PowerShell Compress-Archive は path separator バグで使えない) 罠 1 — manifest.json の key フィールド 課題 複数 PC で unpacked load する場合、manifest.json に key を入れることで EXTENSION_ID を固定できる。固定しないと、PC ごとに違う EXTENSION_ID が生成されて、Google OAuth の redirect URI を PC 数ぶん登録しないといけなくなる。 ところが、この key フィールドが入った状態でストアにアップロードすると弾かれる。 試した順 手動で key を削除 → zip 作成 → 開発に戻る時に書き戻す。明らかにミスの温床。 zip 化スクリプトで動的除外 ← 採用 採用案: zip 化時に動的除外 scripts/zip_extension.py で manifest.json を扱う際だけ key を抜く。ローカル開発用の chrome-extension/manifest.json には key を残したまま。 なぜこの方法か | 方法 | 開発時 | 提出時 | 再現性 | |---|---|---|---| | 手動で削除 / 戻す | ◯ | ◯ | × (ミス源) | | 開発用と提出用で 2 ファイルに分ける | ◯ | ◯ | △ (両方を同期する手間) | | zip 化時に動的除外 | ◯ | ◯ | ◎ (1 ファイル + スクリプト) | 罠 2 — アイコンサイズの不一致 課題 アップロード再試行で key は通った。次は「ファイルのアップロード中に問題が発生しました」。 確認すると、chrome-extension/icons/icon-{16,48,128}.png の 3 ファイルが全部 1254x1254 だった。ファイル名は 16 / 48 / 128 だが、中身は同じ巨大画像のコピー。 3 ファイルの SHA-256 を取ったところ、全部同じハッシュ。過去に「同じ画像を 3 回コピペして名前だけ変えた」状態だった。 Chrome 自体はインストール時に自動リサイズして表示するので、これまで動作上の問題は出ていなかった。だがストアのファイル検証はそれを見逃してくれない。 対処 単一ソースから Lanczos で 3 サイズを再生成。 副作用: zip サイズが 1/16 に | 項目 | Before | After | |---|---|---| | icon-16.png | 1254x1254 (2.3 MB) | 16x16 (0.4 KB) | | icon-48.png | 1254x1254 (2.3 MB) | 48x48 (4 KB) | | icon-128.png | 1254x1254 (2.3 MB) | 128x128 (21 KB) | | zip 全体 | 7.5 MB | 471 KB | ユーザーがインストール時にダウンロードするバイト数も同じだけ減る。長期的な無駄を解消できた。 「拡張同梱アイコン」と「ストアアイコン」は別ファイル もう一点、提出時に判明した区別: | アップロード先 | 内容 | サイズ | |---|---|---| | zip 内 icons/ | 拡張機能本体 (manifest の icons フィールドから参照) | 16 / 48 / 128 | | Dashboard「ストア掲載情報」 | ストア表示用 (一覧・詳細ページに出る) | 128 のみ | 両方とも 128x128 だが、独立してアップロードする。同じ絵柄にしたければ自分でファイルを揃える必要がある。 教訓 key フィールドはローカルに残して zip では抜く。手動でなく zip 化スクリプトで自動化する。 アイコンのファイル名と実サイズを定期チェック。PIL.Image.open().size を回せば 1 行で全部出る。 「実装が終わった = 提出できる」ではない。実装フェーズと提出フェーズで使う集中力は別物。提出は別セッションに切るのが安全。 zip 内の無駄ファイルは長期的なダウンロード負債。1254x1254 のアイコンを 7 MB 載せていた事故は、本来 0.4 KB で済むものだった。 参考 Chrome Web Store Developer Documentation — Manifest fields Chrome Extensions Manifest V3 — icons
Chrome 拡張機能 (Manifest V3) を日英対応する:messages.json + applyI18n() ヘルパーパターン (petapeta)
Chrome 拡張機能 (Manifest V3) を日英対応する:messages.json + applyI18n() ヘルパーパターン (petapeta) Chrome ウェブストアに自作の拡張機能「petapeta」(ブックマーク管理 PWA + Chrome 拡張) を出すにあたって、UI を日本語と英語の両方で表示できるようにした。 Chrome 拡張の i18n は専用の API (chrome.i18n.getMessage) が用意されていて仕組み自体はシンプルなのだが、実装するとちょこちょこ罠があった。記録として残す。 概要 | 項目 | 内容 | |---|---| | 対象 | Manifest V3 の Chrome 拡張機能 | | キー数 | 約 70 個 | | 対応言語 | 日本語 (デフォルト) / 英語 | | 並行課題 | Web 版と共有しているモジュールの扱い | ディレクトリ構造 messages.json の設計 各キーは下記の構造で書く。placeholders で動的部分を扱える。 呼び出し側: キー命名は camelCase で機能別プレフィックス 70 個も並ぶと整理が要る。私は以下のプレフィックスで分けた: | プレフィックス | 用途 | 例 | |---|---|---| | ext | 拡張機能メタ | extDesc | | btn | ボタンラベル | btnSave | | label | フォームラベル | labelTag | | placeholder | input/textarea | placeholderMemo | | title | title 属性 | titleSync | | aria | aria-label | ariaPlanBannerClose | | tab | タブ | tabLink | | msg | Toast / 通知 | msgSaveDone | | state | 動詞系 (一時状態) | stateSaving | | err | エラー | errLoginRequired | | banner | 上限バナー | bannerOverFree | | confirm | confirm() | confirmBulkPartial | 並列項目の区切りは sep キーで切り替え 「ブクマ 320/300、コレクション 6/5」のような複数項目を結合する箇所は、日本語と英語で区切り文字が違う。 呼び出し: これをしておくと、英訳した時に「Bookmarks 320/300、Collections 6/5」と読点が混入する事故を防げる。 manifest.json の __MSG__ 化 default_locale を入れて、ストア表記に出る description を __MSG_extDesc__ 化する。 name は固有名詞でそのまま英字なので i18n 対象外。description だけ messages.json から拾われる。 popup.html の data-i18n- 属性化 JS から DOM を走査して差し替えるために、属性で「ここにこのキーの文字列を入れる」とマーキングしておく。 アイコン + 文言のボタンは span で包む Phosphor Icons のような <i> タグ + 文言のパターンを直接 data-i18n-text で囲うと、textContent 上書きで <i> が消える。 私のケースでは、タブ 3 個 / 保存ボタン 3 個 / タグラベル / フッターリンクが該当した。 applyI18n() ヘルパー DOMContentLoaded で 1 回呼ぶだけで、data-i18n- 属性付きの要素を全部差し替える。 JS から動的に出る文言 Toast / Confirm / innerHTML 動的生成の箇所は、data-i18n 属性で表現できないので chrome.i18n.getMessage を直接呼ぶ。 messages.json にアイコン HTML を入れない判断 <i class="ph-warning"></i> ログインが必要です を messages.json に丸ごと入れる案もあった。一見シンプルだが、 翻訳の修正で HTML が壊れる事故が出やすい アイコンは UI コードの構造、文言は messages.json の役割、と分離した方が保守しやすい ので、messages.json は文字列のみ、アイコンはコード側で ${getMessage(...)} と合成する形に統一した。 Web と共有しているモジュールの扱い 私のケースでは、タグ入力 UI のモジュール (tag-chip-input.js) が Web 版 (PWA) と Chrome 拡張の両方で同じファイルを使い回している。 そのまま chrome.i18n.getMessage('xxx') を書き込むと、Web 版で chrome がそもそも存在せず、ReferenceError で落ちる。 opts 経由で文言差替 モジュールの初期化引数 (opts) に文言を渡せるようにして、デフォルト値を日本語にしておく。 呼び出し側は環境に応じて文言を渡す。 これでファイルは 1 つを共有したまま、両環境で正しく動く。Web 版を将来 i18n 対応するときも、同じ口経由で文言を渡せばいいので拡張性も保てる。 動作確認 — _locales/ja を一時退避して英語フォールバック Chrome の表示言語を英語にする方法もあるが、_locales/ja を一時的にリネームする方が早い。 default_locale が ja なので、ja フォルダがなければ en にフォールバックする挙動を利用している。 英訳品質チェックの観点 AI に英訳させると、 実在しない英単語(例: 過去に他プロジェクトで horaitorous というハルシネ造語が混入したことがある) 英語圏 UX 慣習との微妙なズレ(Memo よりは Note が一般的、View Pro より Upgrade to Pro の方が CTA らしい、等) 冗長な訳(30 bookmarks left until the Free limit. よりは Approaching Free limit: 30 bookmarks left. の方が読みやすい) このあたりは生成後に必ず目視チェックが要る。70 個のうち 5 個ほどを生成後に手で直した。 ZIP 作成は Python の zipfile で ストア提出用の zip を作る時、Windows の PowerShell Compress-Archive を使うと path separator がバックスラッシュになり、Linux 系の解凍で壊れる事故がある。Python の zipfile モジュールで作る。 as_posix() で / 区切りを強制している箇所がキモ。 まとめ Chrome 拡張の i18n 化で押さえたいポイントを並べると、 messages.json に placeholders で動的部分を扱える 並列項目の区切り (、 / , ) も sep キーで言語切替する アイコン + 文言のボタンは <span> で囲って data-i18n-text を span に付ける applyI18n() ヘルパーで DOMContentLoaded 時に一括差替 動的文言は chrome.i18n.getMessage で都度取得 messages.json にアイコン HTML を入れず、コード側で合成 Web と共有モジュールは opts 経由で文言差替 英訳は生成後に必ず目視チェック ZIP は Python zipfile で作る 仕組み自体は素直だが、罠を踏まないように設計するとファイル数の多いプロジェクトでも保守可能な作りにできる。 参考 chrome.i18n API リファレンス (Chrome Developers) Internationalize your extension (Chrome Developers) Locale-Specific Messages (messages.json の仕様)
Substack Comment Keeper Phase B:fetch hook で「自分の返信」を自動取り込み
Substack Comment Keeper Phase B:fetch hook で「自分の返信」を自動取り込み Chrome 拡張 Substack Comment Keeper (SCK) の Phase B 実装記録。 SCK は Substack の通知ページ (substack.com/activity) で「返信したか / してないか」を管理する個人ツール。Phase 1 までは DOM 読み取りベースで動作していたが、Phase B で fetch hook を導入して「自分の返信本文」と「自動『対応済』化」を実現した。 出発点:DOM だけでは取れない情報 /activity の通知 DOM には以下が含まれる: 相手 (actor) のハンドル、表示名、本文 snippet 親 note / 親 post へのリンク 相対時刻 (2h, 3d 等) しかし、コメント本体への直接 URL (@handle/note/c-XXXXXX) は静的属性に存在しない。Substack の React アプリが onClick で動的生成している。さらに「自分がそのスレッドで何と返信したか」は当然 DOM には出てこない (= 自分の発言は別ページにある)。 → API レスポンスを傍受する fetch hook で補う設計に。 アーキテクチャ:MAIN world + ISOLATED world Chrome Extension Manifest V3 の content_script は 2 つの world で動かせる: | World | 用途 | |---|---| | MAIN | ページの window に直接アクセス可 (= window.fetch を上書きできる)、ただし chrome.* API は使えない | | ISOLATED | chrome.runtime などの拡張 API が使える、ただし window.fetch の上書きはページに反映されない | つまり 2 つを組み合わせる: interceptor.js (MAIN) が window.fetch と XMLHttpRequest を monkey-patch、傍受したレスポンスを window.postMessage で送出。bridge.js (ISOLATED) が chrome.runtime.sendMessage で background へ転送する。 傍受対象の API GET /api/v1/reader/comment/{id} — コメント詳細 Substack 上でコメントを開いた時に走る。レスポンスは: parentComments は X の祖先 chain (自分の過去発言も含む)。photo_url は actor の高画質アイコン。 POST 投稿レスポンス 返信を投稿した時に走る。レスポンスは: ancestor_path の 末尾 が「自分が返信した相手の comment_id」になる。 URL は /api/v1/post/{post_id}/comment 等で publication ごとに変わるので、URL では絞らず レスポンス shape (id + body + ancestor_path + type: "comment") で判定する方が確実。 紐付けの難所:commentId が一致しない content.js は DOM から抽出した情報で comment:{threadId}/{handle}/s-{snippet} というキーを生成して保存している。一方 API レスポンスの commentId は 264917799 のような数値。 直接マッチできない。 解決策は actor handle + snippet マッチ: actor のハンドルが同じ + 本文 snippet (先頭 50 文字) が一致 → 同じコメント。 POSTED の紐付け:recent_detail と pending_post POSTED レスポンスには ancestor 末尾 ID (= 数値) しかなく、それだけでは dashboard レコードを引けない。2 つの経路でカバー: 経路 1:recent_detail (即時反映) DETAIL で handle + snippet マッチが成功した時、recent_detail:{numericId} キーに targetKey を 30 分 TTL で保存: POSTED 受信時、ancestor 末尾 ID で recent_detail を引けば即時 dashboard に反映できる。 経路 2:pending_post (フォールバック) POSTED が DETAIL より先に来た場合 (= ユーザーが /activity を経由せずに直接記事ページから投稿した時など) は、pending_post:{numericId} に投稿本文を一時保存: 後で同 ID の DETAIL が来たら、その pending を消費 (consumePending) して dashboard を更新する。 自動「対応済」化の設計:parents 経由は誤判定 最初の実装では「DETAIL の parents 内に自分の発言があれば自動マーク」としていたが、これは誤判定を起こす: 自分のコメント A 他人の reply X (parents=[A]) この場合、X の通知が /activity に出る。parents には A (自分) が含まれているので「対応済」化されてしまうが、ユーザーは X 自体にはまだ返事していない。 → 修正: POSTED 時 (= 自分が X 自体に返信投稿した時) のみ 自動「対応済」化。GSM (Gemini Share Manager) と同じ設計思想:「拡張入れた時点以降の POSTED のみ管理、過去 chain は触らない」。 アイコン取得:DOM scrape + API ハイブリッド actor のアバター画像は 2 経路で取得: DOM scrape (content.js の extractEntry): /activity に表示された瞬間に actor link 内の <img src> を取得 API レスポンス (/api/v1/reader/comment/{id}): comment.photo_url から高画質版 既存値があれば優先 (= API 経由で取れた高画質版は維持)、なければ DOM scrape の値で埋める。 5 テーマシステム petapeta と Save & Theme for Google Collections に合わせて、SCK にも 5 テーマを実装: Substack (default オレンジ) ほんわか (淡ピンク + ベージュ) ゆめかわ (ローズ + パステル紫) ミント (ミントグリーン) ギャラクシー (ダーク + パープル) CSS 変数で全体管理: ハマりどころ:スクロールバーは <html> レベル document.body.dataset.theme = "galaxy" だけだとスクロールバーがテーマに追従しない。スクロールバーは <html> (documentElement) で描画されるため、CSS 変数が伝搬しない。 両方にセットして解決。 ハマりどころ:replace_all で循環参照 ハードコード色 #e55810 を var(--accent-hover) に一括置換した際、:root 内の --accent-hover: #e55810; も置換されて --accent-hover: var(--accent-hover); という循環参照が発生。 教訓: replace_all は :root の変数定義をスコープ外する手順が必要。 TOS との関係 SCK は以下を遵守: 自分から fetch しない (= Substack 自身が発した通信を受動傍受するだけ) 自動投稿しない (= 投稿は本人手動) データ外部送信なし (chrome.storage.local のみ) 本人セッションのみ (= ログイン中の自分のブラウザのみ) GSM 拡張で同じ fetch hook 方式が Chrome Web Store 公開済 (2026-05-08)、現在も稼働中。Substack のサーバーから見た場合、SCK の存在は技術的に検知できない (= 通常のブラウザ利用と区別がつかない)。 残タスク #99 PRIVACY.md 執筆 (scripting permission の justification) #100 STORE_LISTING.md + スクリーンショット + Web Store 申請 #103 (調査) DM / Threads の未返信検知の実現可能性 #104 いいね通知の取り込み (ファン発見用) --- (コミット: df79a39、未 push 状態)
マイページ管理画面の UX 基盤を切り直す — 共通カード改善 + リスト基盤 + 独立記事の UI 差別化
マイページ管理画面の UX 基盤を切り直す — 共通カード改善 + リスト基盤 + 独立記事の UI 差別化 結論(先に) Mintor のマイページ管理画面 (記事 / 作品 / 相談サービス / お気に入り) で、これまで ContentCard をグリッドで並べる作りだったが、件数が増えてきて編集動線が見えにくくなった。 このセッションでやったこと: 共通カードの改善 (ContentCard): ステータスを左ボーダーで、編集ボタン常時表示、aspect-[4/5] 縦長、日付表示、メタ情報を2段に リスト表示の基盤 (MypageList/): 4 ページ共通で使える ContentListItem + MypageLayout (サイドバー + メイン) + MypageListHeader (検索 + ソート + Layout toggle) + useMypageListState (永続化) 独立記事と Product 紐付け記事の UI 差別化: Product 紐付けは画像上に Product バッジを重ねる、独立記事は「個別記事」バッジを画像上に重ねる、ステータス色も別 実装は Phase 1: 基盤コンポーネントを作るだけ で止めた。実際の 4 ページ移行は Phase 2 以降。 課題: グリッドだけだと管理動線が見えにくい カードレイアウト自体は読者側の UX としては悪くない。並んだ作品が眺められて、雰囲気がある。 ただ管理者側 (= 投稿した本人) の動線は変わる。 「あの記事、編集ボタンどこ?」が起きる (ホバーで出すと PC でしか触れない、モバイルは長押しになる) 件数が増えると「公開済み / 下書き」「Product 別」「期間別」を絞り込む UI が要る 公開日や更新日が見えないと「これ最後にいじったの何月だっけ?」が分からない zenn / Qiita / note の管理画面を見比べた: | サービス | 公開ページ | 管理ページ | |---|---|---| | zenn | カード (グリッド) | リスト | | Qiita | カード (グリッド) | リスト | | note | グリッドとリスト両方 | リスト | 公開ページと管理ページは別物。管理ページは リスト + 並び替え + 検索 + 一括操作 が王道。 設計判断: 一時しのぎはしない 最初は「/mypage/articles だけリストにする」とも考えたが、 作品 (/mypage/products) も同じ問題があるはず 相談サービス (/mypage/consultation-services) も お気に入り (/mypage/favorites) も ばらばらに直すと、各ページで微妙にレイアウトが違う管理 UX が4種類できて負債になる。 4 ページ共通の基盤コンポーネントから作る ことにした。今回は基盤だけ。実際の移行は Phase 2-3 に分ける。 共通カード (ContentCard) の改善 リスト化する前に、まずグリッド表示の ContentCard 自体を一段改善した。リストとグリッドの両方を選べるようにする前提で、グリッド側も使えるレベルに引き上げる。 Before / After | 項目 | Before | After | |---|---|---| | ステータス表示 | バッジのみ | 左ボーダー色 + バッジ | | 編集ボタン | ホバーで出現 | 常時表示 (右下) | | カードの比率 | aspect-square | aspect-[4/5] (縦長) | | 日付 | なし | 「公開 5/24」「更新 5/24」 | | メタ情報 | 1段 (アイコン群と日付が重なって押し込まれる) | 2段 (日付行 + メトリクス+アクション行) | ステータス色 note 風に左 4px のカラーボーダー。ドット + テキストでも表示するので、色覚多様性配慮にもなる。 編集ボタン常時表示 「カードの下にボタンが押し込まれてて、編集ボタンが見えてない」を、aspect-[4/5] + メタを2段にすることで解消。 リスト基盤 (MypageList/) 新規ディレクトリ src/components/common/MypageList/ に共通コンポーネント群を置いた。 ContentListItem の構造 note / Zenn 風の1行レイアウト: ステータスは左4pxのカラーボーダー + ドット + テキストの三重表示。色だけだと見落とすので。 useMypageListState の永続化 layout (list / grid) / sort / sidebar (選択中の絞り込み) を localStorage に保存 search は永続化しない (毎回リセット) ページごとに storageKey を分ける (mypage-list:articles 等) モバイルではサイドバーをコラプス PC は左 240px の固定サイドバー、モバイルは上に「絞り込み」ボタンで開閉。 独立記事と Product 紐付け記事の UI 差別化 /mypage/articles では「Product に紐付いた記事」と「独立記事 (個別記事)」が混在する。これまで同じ見た目で並んでいたが、ユーザー数が増えてきて性質の違いが出てきた。 表示の差別化 (Phase D-1 として実装) Product 紐付け記事: カード画像の左上に Product サムネ + Product 名 をオーバーレイ 独立記事: カード画像の左上に 「個別記事」バッジ (amber カラー) をオーバーレイ ContentCard に categoryIconUrl prop を追加して、Product のサムネ画像を渡せるようにした: Product 側のサムネは products.thumbnail_url を引いてくる (以前は icon_url を引いていたが、Mintor では icon_url が共通アイコンになっているのでサムネが全部同じに見えていた)。 ステータス色の差別化 ステータスの左ボーダー色は、Product 紐付け / 独立を問わず一律で運用。「機能の性質」と「ステータス」は別軸なので、色を共有しないと混乱する。 教訓: 基盤コンポーネントは「移行先がない状態」で先にコミット このセッションのコミット履歴: Phase 1 = 基盤コンポーネント、Phase 2 以降 = 実際の 4 ページ移行、と分けた。基盤だけのコミットは「動作確認できない (= UI 上では何も変わらない)」状態でコミットすることになる。 これを嫌って「基盤 + 1 ページ移行までセットで」とやると、 コミットが大きくなる 「移行先 1 つで OK か」のチェックだけで進めてしまって、後で他ページ移行で API のミスに気づく レビューしにくい 基盤は基盤だけでコミット、移行は次セッションで のリズムにすると、 体力的に区切れる 基盤の TypeScript 型は使われる前に確定する 移行作業がただの差し替えになる (基盤 API が変わると差し替えが破綻するパターンを避けられる) 「未統合の基盤コミット」を恥ずかしがらない設計にすると、長く触り続けられる。 次にやること (Phase 2 以降) /mypage/articles を ContentListItem + MypageLayout + MypageSidebar に移行 /mypage/products, /mypage/consultation-services, /mypage/favorites に展開 384 件超のページネーション or 仮想スクロール (Phase 4) 一括操作 (bulk actions) の UI (Phase 4)
成長ダッシュボードに内訳カードを足す — Phase 2 (スクショ外に置きたい情報をどう切り出すか)
成長ダッシュボードに内訳カードを足す — Phase 2 (スクショ外に置きたい情報をどう切り出すか) 結論(先に) Phase 1 で作った /admin/analytics/growth に Phase 2 として 内訳カード3種 を追加した。 直近の新規登録ユーザー一覧 期間内の投稿者ランキング グラフ最新ポイントの内訳(記事・作品・相談・コメントを誰が何件出したか) 実装ポイント: 別エンドポイント /api/admin/analytics/growth-breakdown に分離した アコーディオン UI で デフォルト畳んだ状態にした 「スクショ範囲」と「スクショ外」を物理的に分けるための分離 背景: Phase 1 で残した宿題 Phase 1 では「グラフは数のみ、下部にユーザー名等のカードを置く」というレイアウトの枠だけを作って、下部カードは Phase 2 用のプレースホルダーになっていた。 スクショ運用のためのページなので、レイアウト規約は明確だった: Phase 1 完了時点で下部はまだ空っぽ。Phase 2 で「空っぽの下部」に何を入れるかを設計した。 設計: なぜ別エンドポイントに分離したか Phase 1 の /api/admin/analytics/growth はグラフ用集計を返す。ここに「ユーザー名付きの一覧データ」を足すこともできた。 選択肢: | 案 | 内容 | 採用 | |---|---|---| | A. 既存エンドポイントに追加 | GrowthAnalyticsResponse に breakdown フィールドを足す | ❌ | | B. 別エンドポイントに分離 | /api/admin/analytics/growth-breakdown を新設 | ✅ | B を採用した理由は3つ: 権限の分離可能性 — 将来「数字だけは閲覧可、個人特定情報はさらに上の権限」と分けるとき、エンドポイントが分かれていれば middleware で素直に守れる キャッシュ戦略の違い — グラフ集計は数十秒キャッシュしても害は少ないが、内訳は「今この瞬間の最新」を見たい場合がある レイアウトのスクショ規約と一致 — UI 上「スクショする / しない」が物理的に分かれているのと同じ分け方になる API 設計 ポイント: BreakdownUser / BreakdownPoster は display_name, username, avatar_url を返す。これらは スクショに混ぜたくない情報 なので、フロント側で「スクショ外の領域」だけにレンダリングする BreakdownPost の title は記事のタイトル等。これも個人発信内容なので扱い注意 profiles の join articles products consultation_services comments の作者 id を集めて、profiles から表示名・アバターをまとめて引く。 1 回の in クエリで全部引いて Map に詰めるパターン。投稿テーブルそれぞれから FK 経由で取りに行かない(N+1 を避ける)。 投稿者ランキングの集計 各テーブルから「期間内に user_id ごとに何件投稿したか」をカウントして、user_id ごとに 4 種別を合算。最後に totalCount 降順で並べる。 type は SELECT 時にリテラルでつけておく(articles テーブルなら 'articles' 固定)。これで 1 つの配列に統合してから集計できる。 UI 設計 アコーディオンで畳んだ状態がデフォルト 下部の内訳カードは デフォルトで畳まれた状態 にした。理由: 「グラフだけ見て SNS にスクショ」がデフォルト動線 内訳を見たいときだけ展開する 「ここから下はスクショに入れない」が UI 上も明確になる カード3種 | カード | 中身 | 表示件数 | |---|---|---| | 新規登録ユーザー | アバター + 表示名 + 登録日 | 期間内全員 (最大20件) | | 投稿者ランキング | アバター + 表示名 + 種別ごとの内訳数 | 上位10件 | | 最新ポイント内訳 | 種別タブ + 投稿タイトル + 投稿者名 | 種別ごと最大10件 | ランキングは降順、新規登録は新しい順、最新ポイント内訳は時系列降順。 性能上の注意 期間タブが「月次 = サービス開始月〜現在」だと、3-1 のクエリ範囲が大きくなる。 ランキングは「期間内全体」だが、latestPostsBreakdown は「最新バケットのみ」。意図的にスコープを分けて、月次タブを開いてもクエリが膨らまないようにした。 教訓: 「スクショする / しない」を実装の境界に据える 普通の管理画面なら「全部1ページに並べる」が自然だが、SNS スクショを目的にすると 見せたい情報と見せたくない情報が同じ画面に混在するのが負債 になる。 同じレスポンスに混ぜる ❌(フロントで毎回フィルタが必要) 同じセクションに描画する ❌(毎回スクショの切り取りに気を使う) エンドポイント分離 + UI 物理分離 ✅(迷う必要がなくなる) 「毎回判断したくないことを構造に閉じ込める」は、個人開発で疲弊を減らす一番大きな手段だと思っている。 次にやること 内訳カードに「期間内の最大投稿日 / 最新投稿時刻」等のメタを追加するか検討 投稿者ランキングに「累計貢献度との対比」を追加するか検討(応援力との混同に注意) スクショ範囲を視覚的に示すガイド線(点線ボーダー)を引くかどうかは保留