UGCアプリを Rork で初めて App Store に出した翌週、通報メールが30件以上届きました。個人開発で運営しているコミュニティアプリでしたが、想定していなかったタイプの不適切投稿(自傷示唆を含むテキスト、子どもの写真の無断アップロード、有名人の名前を使ったなりすまし)が次々と現れ、対応に1日6時間かかった日もありました。
その経験から学んだのは「モデレーションを最初から3層で設計しておかないと、リリース直後に手作業でつぶれる」ということです。ここではRork で作るUGCアプリに、事前チェック・投稿時の自動審査・事後の通報処理を本番品質で組み込む実装を、私が実際に運用しているコード(Cloudflare Workers + Queue + KV)と共に解説します。
OpenAI Moderation API の使いどころと限界、Perspective API との使い分け、AppleとGoogleのガイドラインで明文化されている要件、DSA(EU デジタルサービス法)と日本の青少年保護条例で求められる対応まで、個人開発者が実装段階で必要になる情報を一気にまとめました。
モデレーションは「事前 / 投稿時 / 事後」の3層で考える
最初のリリースで失敗したのは、すべての判定を「投稿時の自動審査」だけに集約しようとしたことでした。1層に集中させると、APIコストが膨らむ・誤判定の影響範囲が広い・通報後の追加対応ができない、という3つの問題が同時に出ます。
私は今、すべてのUGC機能を以下の3層で設計するようにしています。
事前(Client-side preflight) : ユーザーが投稿ボタンを押す前に、明らかにNGなパターン(空文字、規定文字数超過、URL のみ、絵文字スパム等)をクライアント側で弾く。これでサーバー負荷の40〜60%を削減できます。
投稿時(Server-side moderation) : 投稿が API に届いた時点で、AIモデレーションAPIに非同期で投げ、スコアに応じて「即時公開 / 保留(レビューキュー) / 即時拒否」の3分岐に分ける。
事後(Post-publication) : 公開済みコンテンツに対する通報・追加学習・人間レビュー結果の反映。法令上必要な「アピール窓口」もここに含める。
この3層を Cloudflare Workers の Queue で繋ぐと、コストとレスポンス速度のバランスが取りやすくなります。投稿時はQueueへenqueueだけ即時返し、Workerが裏で判定してKVに結果を書き、フロントは結果が確定するまで「審査中」の状態を表示する、という構成です。
事前チェック:Rork クライアント側のpreflightバリデーション
Rork で作る投稿フォームは React Native コンポーネントになるため、TextInput と ImagePicker の段階で軽量チェックを噛ませます。これは「攻撃」を防ぐためではなく、明らかなノイズを99%減らすためのものです。
// app/components/PostComposer.tsx
import { useState } from "react" ;
import { TextInput, View, Pressable, Text } from "react-native" ;
const NG_PATTERNS = [
/ ^ https ? : \/\/ \S +$ / , // URLのみの投稿は禁止
/( . ) \1{20,} / , // 同じ文字を21回以上繰り返す
/ [ \u {1F300}- \u {1F9FF}] {30,} / u , // 絵文字を30個以上連続
];
const MIN_CHARS = 2 ;
const MAX_CHARS = 1000 ;
type PreflightResult =
| { ok : true }
| { ok : false ; reason : string };
function preflight ( text : string ) : PreflightResult {
const trimmed = text. trim ();
if (trimmed. length < MIN_CHARS ) {
return { ok: false , reason: "投稿は2文字以上必要です" };
}
if (trimmed. length > MAX_CHARS ) {
return { ok: false , reason: `投稿は${ MAX_CHARS }文字以下にしてください` };
}
for ( const pattern of NG_PATTERNS ) {
if (pattern. test (trimmed)) {
return { ok: false , reason: "投稿の形式が不正です" };
}
}
return { ok: true };
}
export function PostComposer ({ onSubmit } : { onSubmit : ( text : string ) => void }) {
const [ text , setText ] = useState ( "" );
const [ error , setError ] = useState < string | null >( null );
const handleSubmit = () => {
const result = preflight (text);
if ( ! result.ok) {
setError (result.reason);
return ;
}
setError ( null );
onSubmit (text. trim ());
setText ( "" );
};
return (
< View >
< TextInput
value = {text}
onChangeText = {setText}
multiline
placeholder = "今、何が起きていますか?"
maxLength = {MAX_CHARS + 100 }
/>
{ error && < Text style = {{ color : "#d33" }} > {error} </ Text > }
< Pressable onPress = {handleSubmit} >
< Text >投稿する </ Text >
</ Pressable >
</ View >
);
}
このpreflightは「サーバー側のバリデーションを置き換えるためのもの」ではない点に注意してください。クライアントは改ざん可能なため、サーバー側でも同じチェックを通すことが前提です。preflightの目的はあくまでUX(無駄なAPI呼び出しを抑え、即座にエラー表示する)とサーバー負荷の軽減です。
なぜこの3パターンを最初に入れているかというと、UGCの初期リリースで実際に集中して発生するパターンだからです。URL Only投稿はスパム業者の偵察、文字繰り返しはスクリプトキディの動作テスト、絵文字スパムは10代ユーザーの「炎上参加」の典型形です。1日5,000投稿のうち約30%がこのパターンに該当した時期があり、preflightだけでサーバー負荷を3割削減できました。
投稿時の自動審査:OpenAI Moderation と Perspective API の使い分け
投稿が届いたサーバー側では、AIモデレーション APIで分類します。私が比較した上で実運用に落としたのは、テキストは Perspective API、画像とテキストの追加チェックは OpenAI Moderation、という二段構成です。
Perspective API(Google Jigsaw) : 無料で使える。TOXICITY SEVERE_TOXICITY IDENTITY_ATTACK INSULT PROFANITY THREAT の6カテゴリ。日本語・英語・スペイン語など多言語対応。レート制限は1QPS(申請で増枠可)。
OpenAI Moderation API : 完全無料(omni-moderation-latest)。hate harassment self-harm sexual violence 等の細分類。画像入力にも対応(omni-moderation-latest以降)。レスポンス時間は平均200ms。
Vision系の画像専用判定 : AWS Rekognition Content Moderation、Google Cloud Vision SafeSearch、Azure Content Moderator。NSFW・暴力・武器の画像分類はこちらが精度高い。
実装での使い分けは「テキストはPerspectiveで第一選別 → 不審ならOpenAIで再評価 → 画像はRekognition」という流れにしています。Perspectiveは多言語対応が強い一方で、文脈理解(皮肉・自傷の暗示等)はOpenAIの方が優れているからです。両方を直列にかけると2倍のレイテンシになりますが、Cloudflare Workers Queue で非同期化すれば体感は変わりません。
サーバー側パイプライン:Cloudflare Workers + Queue + KV
以下が、私が実際に運用している投稿パイプラインの中核コードです。重要なのは「投稿APIは即時返す・判定は裏で走る・結果はKVから読む」の3点です。
// workers/src/post-router.ts
import { Hono } from "hono" ;
type Env = {
POSTS_KV : KVNamespace ;
MODERATION_QUEUE : Queue < ModerationJob >;
PERSPECTIVE_API_KEY : string ;
OPENAI_API_KEY : string ;
};
type ModerationJob = {
postId : string ;
userId : string ;
text : string ;
imageUrls : string [];
createdAt : number ;
};
const app = new Hono <{ Bindings : Env }>();
app. post ( "/api/posts" , async ( c ) => {
const { text , imageUrls = [] } = await c.req. json ();
const userId = c.req. header ( "X-User-Id" );
if ( ! userId) {
return c. json ({ error: "Unauthorized" }, 401 );
}
// サーバー側でも改めてpreflightを実行(クライアント改ざん対策)
if (text. trim (). length < 2 || text. length > 1000 ) {
return c. json ({ error: "Invalid length" }, 400 );
}
const postId = crypto. randomUUID ();
const post = {
id: postId,
userId,
text,
imageUrls,
status: "pending_moderation" as const ,
createdAt: Date. now (),
};
// 投稿はpendingでKVに即時保存し、ユーザーには202を返す
await c.env. POSTS_KV . put ( `post:${ postId }` , JSON . stringify (post), {
expirationTtl: 60 * 60 * 24 * 30 , // 30日でクリーンアップ対象
});
// モデレーションをQueueに非同期enqueue
await c.env. MODERATION_QUEUE . send ({
postId,
userId,
text,
imageUrls,
createdAt: post.createdAt,
});
return c. json ({ postId, status: "pending_moderation" }, 202 );
});
app. get ( "/api/posts/:id" , async ( c ) => {
const id = c.req. param ( "id" );
const post = await c.env. POSTS_KV . get ( `post:${ id }` , "json" );
if ( ! post) return c. json ({ error: "Not found" }, 404 );
return c. json (post);
});
export default app;
なぜ即時返しの設計にするかというと、ユーザー体験のためだけではなく、誤判定が起きたときに「公開されてから取り消す」より「審査中で止めておく」方が、復旧コスト・心理的負担の両面で軽いからです。Twitter/Xの「シャドウバン」のような不透明な仕組みではなく、ユーザーに「審査中です」と明示する設計のほうが、結果的にトラブルが少ないという印象を持っています。
Queueを処理するconsumer Workerは以下のような構成です。
// workers/src/moderation-consumer.ts
type Verdict = "approved" | "rejected" | "needs_review" ;
async function checkText ( text : string , env : Env ) : Promise < Verdict > {
// ① Perspective APIで第一選別
const perspective = await fetch (
`https://commentanalyzer.googleapis.com/v1alpha1/comments:analyze?key=${ env . PERSPECTIVE_API_KEY }` ,
{
method: "POST" ,
body: JSON . stringify ({
comment: { text },
requestedAttributes: {
TOXICITY: {},
SEVERE_TOXICITY: {},
THREAT: {},
SEXUALLY_EXPLICIT: {},
},
languages: [ "ja" , "en" ],
}),
},
). then (( r ) => r. json () as Promise < PerspectiveResponse >);
const scores = perspective.attributeScores;
const severe = scores. SEVERE_TOXICITY .summaryScore.value;
const threat = scores. THREAT .summaryScore.value;
if (severe > 0.8 || threat > 0.7 ) return "rejected" ;
if (severe < 0.3 && threat < 0.3 ) {
// 明らかに安全な投稿はOpenAIをスキップしてコスト削減
return "approved" ;
}
// ② グレーゾーンはOpenAIで再評価
const openai = await fetch ( "https://api.openai.com/v1/moderations" , {
method: "POST" ,
headers: {
Authorization: `Bearer ${ env . OPENAI_API_KEY }` ,
"Content-Type" : "application/json" ,
},
body: JSON . stringify ({ model: "omni-moderation-latest" , input: text }),
}). then (( r ) => r. json () as Promise < OpenAIModerationResponse >);
const result = openai.results[ 0 ];
if (result.flagged) {
// 自傷示唆・未成年保護関連は例外なくrejected
if (
result.categories[ "self-harm/intent" ] ||
result.categories[ "self-harm/instructions" ] ||
result.categories[ "sexual/minors" ]
) {
return "rejected" ;
}
// それ以外のflaggedはneeds_review(人間レビュー)
return "needs_review" ;
}
return "approved" ;
}
export default {
async queue ( batch : MessageBatch < ModerationJob >, env : Env ) {
for ( const message of batch.messages) {
const job = message.body;
try {
const textVerdict = await checkText (job.text, env);
const post = ( await env. POSTS_KV . get ( `post:${ job . postId }` , "json" )) as Post | null ;
if ( ! post) {
message. ack ();
continue ;
}
post.status =
textVerdict === "approved"
? "published"
: textVerdict === "rejected"
? "rejected"
: "needs_review" ;
post.moderatedAt = Date. now ();
post.moderationVerdict = textVerdict;
await env. POSTS_KV . put ( `post:${ job . postId }` , JSON . stringify (post));
message. ack ();
} catch (err) {
// 失敗時はretryさせる(maxRetries到達時はDLQへ)
message. retry ({ delaySeconds: 30 });
}
}
} ,
} ;
エラーハンドリングでmessage.retry()を使っているのは、APIの一時的失敗で投稿が永久にpendingに留まる事故を避けるためです。wrangler.tomlでmax_retries = 3とdead_letter_queueを設定しておけば、3回失敗した投稿は人間レビューキューに自動で回ります。
ユーザー通報フローと「即時ミュート」の設計
通報は「公開済みコンテンツ」に対する事後対応です。重要なのは、通報を受けた瞬間に該当ユーザーから当該コンテンツを「見えない状態(soft mute)」にすることです。完全削除や永久BANは後で判断すればよく、まずは通報者が安心できる状態を作ることが信頼につながります。
// workers/src/report-handler.ts
app. post ( "/api/posts/:id/report" , async ( c ) => {
const postId = c.req. param ( "id" );
const reporterId = c.req. header ( "X-User-Id" );
const { reason , detail } = await c.req. json ();
if ( ! reporterId || ! reason) {
return c. json ({ error: "Bad request" }, 400 );
}
const reportId = crypto. randomUUID ();
await c.env. REPORTS_KV . put (
`report:${ postId }:${ reporterId }` ,
JSON . stringify ({
reportId,
postId,
reporterId,
reason,
detail: detail?. slice ( 0 , 500 ),
createdAt: Date. now (),
}),
);
// 通報したユーザーから当該投稿を即座にmute(自動非表示)
const muteKey = `mute:${ reporterId }:${ postId }` ;
await c.env. MUTES_KV . put (muteKey, "1" );
// 同一投稿への通報数が閾値を超えたら自動的にneeds_reviewへ移行
const reportCount = await incrementReportCounter (c.env, postId);
if (reportCount >= 3 ) {
const post = ( await c.env. POSTS_KV . get ( `post:${ postId }` , "json" )) as Post | null ;
if (post && post.status === "published" ) {
post.status = "needs_review" ;
post.flaggedReason = "auto_threshold" ;
await c.env. POSTS_KV . put ( `post:${ postId }` , JSON . stringify (post));
}
}
return c. json ({ ok: true , reportId });
});
async function incrementReportCounter ( env : Env , postId : string ) : Promise < number > {
const key = `report_count:${ postId }` ;
const current = parseInt (( await env. COUNTERS_KV . get (key)) || "0" , 10 );
const next = current + 1 ;
await env. COUNTERS_KV . put (key, String (next), { expirationTtl: 60 * 60 * 24 * 7 });
return next;
}
「3件で needs_review に切り替える」のは、私の運用での経験値です。1件だと悪意ある通報攻撃に脆弱で、5件以上だと反応が遅すぎてユーザーが離脱します。3件はバランスが良く、誤通報による誤BANも実例ではほぼゼロでした。
段階的制裁(警告→一時停止→永久BAN)とアピール窓口
Apple App Store Review Guidelines 1.2 と Google Play Developer Program Policy では、UGCを扱うアプリに「ブロック・通報・モデレーション・適切な制裁」の4要素が求められます。さらにEUのDSA(デジタルサービス法、2024年完全施行)では「制裁を受けたユーザーに異議申立(appeal)の窓口を提供する」ことが義務付けられました。
私が実装している段階的制裁は以下のとおりです。
第1段階(警告・soft warning) : アプリ起動時に「投稿XXXがコミュニティガイドラインに違反した可能性があります」というモーダルを1度表示。投稿は削除しありません。再発までのカウントダウン7日。
第2段階(一時停止・48時間) : 投稿・コメント・通報以外の機能(閲覧・通報応答)は使える。リードオンリー状態。
第3段階(一時停止・7日) : 第2段階と同じ機能制限を1週間。
第4段階(永久BAN) : アプリ内のあらゆる書き込み機能を停止。閲覧のみ可能。アカウント削除は本人申請のみ。
各段階で必ず「異議申立フォーム」へのリンクを表示し、24時間以内に人間が確認する運用にしています。これがDSA対応とApple/Googleの審査対応の両方を一度にクリアできるためです。Apple Privacy Nutrition Labels の「User Generated Content」項目にも、この異議申立フローを明記する必要があります。
異議申立を受けた後の「再判定」は、AIではなく人間が行う点が肝心です。AIに再判定させると、同じモデルが同じ入力に対して同じ判定を返すだけで、ユーザーの納得感がゼロになります。私は週次で30分だけ「Appeal Review Time」を確保し、まとめて処理しています。
法的要件:DSA / こども保護条例 / Apple/Google ガイドライン
法令対応は地味ですが、ここを甘く見ると審査リジェクト・行政指導・最悪の場合は罰金まで発展します。個人開発者として最低限抑えるべき要件をまとめます。
DSA(EU、2024年完全施行) : ユーザー数や規模により対応レベルが変わる。年間ユーザー数4,500万人以下のSME(中小事業者)でも、通報フォーム・異議申立・透明性レポート(年1回)が必要。
改正プロバイダ責任制限法(日本、2022年改正) : 発信者情報開示の手続きが簡素化。違法情報の通報窓口設置と、削除依頼への合理的期間内対応が事実上必須。
東京都青少年健全育成条例(自治体ごとに類似法あり) : 18歳未満が利用可能なUGCサービスは、性的・暴力的・自殺示唆コンテンツの「効率的な対処」を求められる。
Apple App Review Guideline 1.2 : 通報・ブロック・モデレーション・適切な制裁の4要素が必須。違反するとリリース後でもアプリ削除リスクあり。
Google Play Developer Program Policy "User Generated Content" : 上記Appleと同等の要件 + 子ども向けセクション(Designed for Families)に出す場合の追加要件。
実装上の対応としては、/legal/transparency-report のような透明性レポートページを作り、四半期ごとの「通報数・自動拒否数・人間レビュー数・異議申立数・承認/棄却数」を公開しておくのが最もコスパが良いです。これでDSA対応・透明性証明・ストア審査対応の3つを同時にクリアできます。
よくある間違いと落とし穴
UGCモデレーションで個人開発者が陥りがちな失敗を、私自身の経験を含めてまとめます。
1. 「通報されたら即削除」を実装してしまう
これは荒らしの格好の的です。複数アカウントから一斉通報するだけで気に入らないユーザーを黙らせられるため、コミュニティの自浄能力を破壊します。通報数の閾値+人間レビュー を必ず挟むこと。
2. AIモデレーションを「最終判定者」にする
OpenAI Moderation も Perspective API も、誤判定率は約3〜8%(英語、日本語ではさらに高い)あります。AIをfinal verdictにすると必ずユーザーから「なぜ削除された?」のクレームが届き、対応に時間がかかります。AIは「自動拒否(明らかにNG)」「自動承認(明らかにOK)」「needs_review(グレー)」の3クラスに振り分けるだけにし、グレーは人間が見る運用が現実的です。
3. 自傷示唆・未成年保護の例外処理を忘れる
通常のモデレーションは「閾値以上ならreject」で良いのですが、self-harm/intent self-harm/instructions sexual/minors の3カテゴリだけは閾値に関係なく即座にreject + 即座に支援リンク表示(自傷の場合)が必須です。これはApple/Googleガイドラインだけでなく倫理的にも譲れない一線です。
4. 多言語対応を忘れる
日本語アプリでも英語のスパムが大量に来ます。Perspective API は languages: ["ja", "en"] を指定しないと、日本語投稿の判定精度が落ちます。逆に英語投稿に ["ja"] だけを指定すると、英語が判定対象外になります。リクエストごとに franc 等のライブラリで言語自動判定するのがおすすめです。
5. 通報者にも識別子を残す
通報者IDを匿名化したくなりますが、悪意ある通報の連発を見つけるためには「通報者ID別の通報数」をトラッキングしておく必要があります。1人で1日100件通報するユーザーは、ほぼ確実に通報攻撃を行っています。
6. Cloudflare Workers の Queue Backlog を監視していない
Queueに溜まる投稿が増えると、ユーザー側で「審査中」のまま何時間も止まることになります。wrangler queues consumer メトリクスを監視し、backlog > 100 でアラートを飛ばす設定を最初から入れておくこと。
本番投入前のチェックリスト
リリース直前に、以下を1つずつ確認してください。私自身がリリース前に1日かけて回しているチェックリストです。
投稿APIが202を返し、KVに pending_moderation で保存されることを確認
Queueが投稿を5秒以内にpickupし、Workerがモデレーションを実行することを確認
明らかなNG投稿(暴言・自傷示唆・性的少数者攻撃)が rejected になることを実投稿でテスト
明らかなOK投稿(普通の日記、写真共有)が approved になることをテスト
通報フォームから3件入れたら、自動的に needs_review に切り替わることを確認
異議申立フォームから送信した内容が、管理者メールに届くことを確認
透明性レポートページが /legal/transparency-report で表示されることを確認
App Privacy Nutrition Labels で "User Generated Content" を ON にし、ブロック・通報・モデレーション機能を申告
Google Play Console の "User-generated Content" 質問に回答(あり / 通報機能あり / モデレーション機能あり)
自傷支援リンク(いのちの電話 等の地域別リンク)が、self-harm判定時に表示されることを確認
ここまで揃えば、Apple/Google 審査・DSA・国内条例の3軸で「合格点」のUGCアプリになります。完璧なモデレーションは存在しませんが、3層設計+自動審査+人間レビュー+異議申立の組み合わせは、現時点で個人開発者が現実的に実装できる最善の構成だと考えています。
Cloudflare Workers の本格運用に踏み込むなら、Hono や Workers Queues の公式ドキュメントを並行して読むのが結局のところ最短ルートです。
リリース後は、最初の1週間は毎日30分だけ needs_review キューを目視確認することをお勧めします。AIの誤判定パターンが見えてくるので、閾値調整の精度が一気に上がります。今日からまず Perspective API の API Key を取得して、ステージング環境で1投稿だけ判定を回してみるところから始めてください。