「ちゃんと聞ける」仕組みを Claude と作った話 — karaha.org の写真同意システム
2026-05-09 / 第5号 / 公開時点では下書き
第 4 号「5 年後を見据えて、karaha.org のロードマップを書いた話」を出して、その日のうちに「サイトとしての整備は一段落」と書いた。
けれど、ロードマップを書きながらずっと気になっていたことが、ひとつあった。
過去のイベントで撮った 27 枚ほどの写真。SNS 投稿用に活動の様子を残してあるけれど、「全員の同意は、ちゃんと取れているのか?」という問いに、私は明確な答えを持っていなかった。
「いちおう聞いた気はする」「対面のときに OK と言われた気がする」── そんな曖昧な記憶だけだった。
当事者団体で、機微情報に関わる写真を扱うのに、これでは足りない。
この記事は、その「足りない」を埋めるために、写真使用の同意を本人ひとりひとりに取るシステムを 2 日かけて自作した記録。技術の話と、運用の話と、最後にタイプミスで一晩止まった話。
なぜ「ちゃんと聞ける仕組み」が必要だったのか
karaha.org は精神保健福祉に関わる当事者・支援者・家族・地域の方々が立場を超えて集まる場(よりみちカフェ川崎)を運営している。
そこで撮った写真は、本人にとって「精神疾患の当事者である」と読み取られうる文脈情報を持つ。
個人情報保護法では、こうした情報は「要配慮個人情報」と呼ばれる。本人の差別・偏見につながりうるので、取得・利用・第三者提供のすべてに、より厳格な配慮が求められる。
そして、写真は本人だけでなく、写り込んだ家族や同伴者、館内の通行人の問題でもある。一度 SNS に出れば、運営の知らないところでスクショされ、検索エンジンにキャッシュされる。「やっぱりやめてほしい」と後から言われたとき、運営はどこまで追いかけられるか。
これまでの運用は「対面でなんとなく了承を取った気がする」だった。それは、運営側だけが安心している状態で、いちばん事故が起きやすい。
「仕組みで守る」に切り替える必要があった。
仕組みの 4 段階
Claude と一緒に、まず「設計」を文字にした。コードより前に、誰が・いつ・何を・どう判断するかを整理した。
- 事前案内:イベント告知・申込時に「撮影あり/なし」を明示。同意しなくても参加できる。
- 当日:撮影 OK / NG をシール等で識別。司会から事前アナウンス。
- 公開前の個別確認:本人ごとに専用 URL を送り、写真ごとに使用範囲を選んでもらう。これが今回の本体。
- 公開後:いつでも撤回できる。年に 1 回、自動でリフレッシュ確認。
使用範囲は写真ごとに 5 段階:
- A:SNS と karaha.org のサイト両方 OK
- B:SNS だけ OK
- C:サイトだけ OK
- D:公開しないでほしい
- ― :私は写っていない/よく分からない
そして「もし将来、連絡が取れなくなったら」も 3 つから事前に選んでもらう。そのまま継続 OK/追加加工なら OK/掲載しないで。
現実には連絡が取れなくなることはある。そのとき運営が即決断するのではなく、本人があらかじめ選んでおく。
顔加工は「花スタンプ」
同意の前に、まず写真自体を匿名化する。Python で OpenCV と YuNet(顔検出)を組み合わせて、19 枚すべての顔に 花のスタンプを被せる処理を書いた。
顔ぼかし、モザイク、印象派風(cv2.stylization)、すりガラス、光のベール、鉛筆スケッチ──。Claude にスタイル候補をいくつか出してもらって、コンタクトシートで一覧比較した結果、団体のあたたかいトーンに合うのは「花のスタンプ」だった。
ちょっとかわいくて、ぼかしほど機械的じゃなくて、一人ずつ違う色になる。
顔検出は YuNet(OpenCV 公式の ONNX モデル)を使用。フロント顔・伏せ顔・マスク顔・横向きの 7 〜 9 顔を、一枚あたりほぼ漏れなく拾えた。残った数枚は、人物の顔が画面に正対していなかったので「もとから識別性が低い」と判断して放置。
連絡不可時用の「全身加工版」も別途生成しておいた。シルエット化+強ぼかしで、顔だけでなく服装・体型まで識別を落とす。今は使わないけれど、運用が長くなれば必ず必要になる。
システム構成
同意ページは「軽くて、運用者ひとりで保てて、長期的に消えない」を満たす構成にした。
本人 → メールで届く専用 URL(karaha.org/consent/?t=...) ↓ Cloudflare Pages(HTML/JS 静的配信) ↓ JS が token を持って fetch Apps Script Web App(API) ↓ Google Sheets(People / Photos / Consents / Audit / Events / EmailHistory) ↓ 画像表示 Cloudinary(顔加工済み画像)
各サービスは無料枠で完結する。本人ごとの URL は UUID 2 つを連結した不可逆トークン。Sheets に同意履歴と監査ログがすべて残る。Apps Script のトリガーで毎週リマインダー、毎月バックアップ、毎日メール枠監視まで自動化。
規模が大きくなったら Cloudflare D1 + Workers に移行する余地は残してあるけれど、たぶん 1 年や 2 年は今の構成で十分。「Claude と二人で運用できる規模」を超えないのがいちばん大事。
ハマりポイント集
2 日間でいくつもハマった。同じところで止まる人がいるかもしれないので、書いておく。
① Cloudinary の Unique filename
19 枚アップロードしたあと、ある画像の Public ID を見たら、ファイル名の末尾にランダムな 6 文字(vkd8hc みたいな)が付いていた。
原因は Upload Preset の "Append a unique suffix"。デフォルトで ON になっていた。
これを OFF にして、19 枚を全削除して再アップロード。Photos シートの cloudinary_id 列も全件作り直し。
「もうアップした、Sheet にも書いた」のあとでこれを直すのは、ほんのり気力を要した。
② Cloudinary の dynamic-folder mode
「フォルダ karaha/consent/ に置く」と「Public ID にパスを含める」は別の話だった。
新しい dynamic folder mode では、Public ID は単に 料理教室_集合写真。フォルダ karaha/consent/ はメタデータとしてだけ存在する。
つまり画像 URL は karaha/consent/料理教室_集合写真 ではなく 料理教室_集合写真 でアクセスする。
これを把握せずに最初は「フォルダパス込み」で URL を組んでいて、19 枚すべて 404 だった。
③ CSP(Content Security Policy)が API を止めていた
karaha.org のセキュリティヘッダー(_headers)には CSP が設定してある。第 3 号の総合監査で入れたものだ。
ところが connect-src は 'self' https://cloudflareinsights.com https://static.cloudflareinsights.com までしか許可していなかった。
同意ページから script.google.com(Apps Script)への fetch はすべてブロックされて、エラーは「Failed to fetch」とだけ。
解決は connect-src に https://script.google.com https://script.googleusercontent.com を追加する 1 行。
でも気付くまで 30 分かかった。「自分で前に追加したセキュリティ設定」が、自分の作った機能を止めるのは、なかなか皮肉な体験だった。
④ ファイル名のスペースと括弧
19 枚のうち 1 枚だけ画像が出ない。
原因は元ファイル名 料理教室_食事会_いただきます (1).jpg のスペースと (1)。Cloudinary が自動的に 料理教室_食事会_いただきます_1 にサニタイズしていた。
それを Sheet 側の cloudinary_id に反映していなかった。
これを機に、CSV 生成スクリプトに Cloudinary と同じサニタイズ規則を実装。今後の追加でも自動で処理される。
⑤ そして、タイプミスがすべてを止めた
本人宛の確認メールは届く。けれど、運営宛の通知メールが届かない。Apps Script の実行ログにはエラーがない。MailApp の残量も十分。
切り分け用の関数を Claude に作ってもらって実行したら、ログに 1 行だけ出ていた。
送信失敗: Exception: Invalid email: jp
「jp」? 何の jp?
その上の行をよく見ると、CONTACT_EMAIL の値が rooo@esynet,jpになっていた。. ではなく ,(カンマ)。
Apps Script はカンマを「複数アドレス区切り」と解釈して、rooo@esynet と jp の 2 通として送ろうとして失敗していた。
Script Properties に手で値をコピペしたとき、私が . と , を打ち間違えていた。
1 文字。直したら、即座に届いた。
私のコードの知識は「大枠を少し読める程度」。Apps Script がカンマを複数アドレス区切りとしてパースすることなんて知らなかった。1 文字のタイプミスがすべてを止める ── 知識の薄い領域でこそ、こういうことは起きる。
「すべて選択しないと送信できない」を見せる
テスト送信のあと、ひとつ UI のフィードバックをもらった。
「すべての写真を選ばないと送信できないことが、見ただけでは分からない」
送信ボタンが disabled になるだけだと、「なんで押せないの?」になる。
そこで:
- 未回答の写真カードに 黄色い枠 + 「未回答」バッジ
- 回答済みの写真カードに 緑の枠 + 「✓ 回答済み」バッジ
- 送信ボタンの上に 「あと 3 / 19 枚が未回答です」のステータス表示
- クリックで「未回答の写真へ移動」スクロール
機能としては変わっていない。でも、「なぜ今これができないのか」を本人に伝えるのは、信頼の土台になる。
残るもの
2 日間で動くシステムができた。技術的には完成。
けれど運用の本番までには、まだ残っているものがある。
・撤回テスト(全撤回・部分撤回)
・一斉送信前のメンバー登録
・共同管理者の確保(今は私 1 人のアカウントですべてが回っている)
・弁護士レビュー(要配慮個人情報を扱うのだから、本来は専門家に見てもらいたい。法人化のタイミングで一気に依頼する予定)
そして、既存 27 枚は同意取得が完了するまで SNS に公開しない。
記事を書くために手元では便利に使ってきたけれど、運用ルールを整えた以上、自分にも適用する。
「ちゃんと聞ける」とは何か
このシステムの中身は技術。けれど、本当に作りたかったのは「本人の意思を聞ける構造」だった。
これまでは、運営側が「対面で OK と言ってもらった気がする」と思っているだけ。本人にも、その同意がどこに記録されていて、いつ撤回できて、何が公開されているのかが見えない。
仕組みがないから「言わない・聞かれない・忘れられる」が起きる。
専用 URL のメールが手元に残れば、本人は「いつでも戻れる」。
撤回はメール 1 通で OK と書いてある。
「もし連絡が取れなくなったら」も先に選んである。
これは、技術じゃなくて文化の話だと思う。
Claude と作っていて、何度も「これは技術的には可能だけど、運用上は本人に判断してもらった方がいい」と境界を引かれた。
たとえば「全員に一斉メールを送る関数」は、誤発射防止のために「自動トリガーには登録しない」と決めた。Claude の側から「これは手動実行のみにすべきです」と提案された。
AI が自動化を進めるほど、人間が決める領域がはっきりする。コミュニティ運営において、これがいちばん心強い。
build-in-public の難所
この記事を書きながら、また同じ線を引いている。
同意システムを作ったプロセス自体は、技術記事として書ける。CSP の話も、Cloudinary の仕様も、タイプミスの話も。
テスト用の本人として登録したのは、私が持っている複数のメールアドレスのひとつ。本人として動かせる本物のテストにはなった。けれど、写真の中身に紐づく具体や、本番で本人ごとに通る同意の選択は、ここには書かない。本番で動き始めれば、その記録は同意してくれた本人のもの。書く側で、自分のテストの記録と混ぜないほうがいい。
build-in-public は「全部見せる」じゃない。
「見せられない部分の輪郭」を見せること、その判断の理由を書くこと。それが、機微情報を扱う団体の運営者として、私が rooo.pro でできる発信の形だと思う。
次は
次回更新は、同意取得が完了して、最初の SNS 投稿が出たときかもしれない。
あるいは、それまでにまた別の壁が来て、ハマる話になるかもしれない。
「決まっていく過程」は、決まりかけている瞬間がいちばん書きやすい。
たぶん、今がそれだ。