6本のアプリを並行してアップデートしていた今年の春のことです。朝いちばんに App Store Connect を開くと、Resolution Center に未読の通知が2件並んでいました。1件はスクリーンショットに関する Guideline 2.3.3、もう1件はプライバシー表記に関する 5.1.1 の指摘でした。アップデートの待ち行列が詰まっているときのリジェクトは、単純に手戻りが増えるだけではありません。「どのアプリの、どの指摘を、どの順番で処理するか」という交通整理そのものが負担になります。この時期は StoreKit 2 への移行や新しい端末解像度への対応をまとめて進めていたため、提出の頻度自体がふだんの数倍になっていて、審査まわりの事務作業が開発時間を目に見えて圧迫していました。
個人開発では、審査対応を代わってくれるチームメンバーはいません。私自身、長くアプリを運用してきた中でリジェクト対応そのものには慣れているつもりでしたが、複数アプリの並行アップデートが重なった時期に、この「読んで・調べて・返信して・直す」の一連の流れを Gemini API で部分的に自動化してみたところ、思っていた以上に効果がありました。ここでは、その仕組みと運用してみて分かった限界をまとめます。
リジェクト通知を「読む」作業がボトルネックだった
最初に、何が時間を食っていたのかを振り返っておきます。リジェクト対応の作業を分解すると、おおよそ次の4段階になります。
通知本文を読み、指摘事項を特定する
該当するガイドライン条文を確認し、何が求められているかを把握する
修正で対応するか、説明(返信)で対応するかを判断する
Resolution Center への返信文を書く、または修正して再提出する
時間を計ってみると、私の場合は1件あたり平均90分ほどかかっていました。意外なことに、最も重いのは修正作業ではなく、1と2の「読む・調べる」の段階です。審査チームからの通知は定型文の中に固有の指摘が埋め込まれた構造をしていて、複数の指摘が1通にまとまっていることも珍しくありません。しかも指摘の粒度はまちまちで、「スクリーンショットを差し替えてください」のような明確なものから、「アプリの機能がガイドラインの要求を満たしているか確認してください」のような解釈の幅が広いものまで混在します。
通知が英語であることも、地味に認知負荷を上げます。読み飛ばしによる誤解は再リジェクトに直結するため、結局は一文ずつ精読することになり、その間ほかのアプリの作業は止まります。段階公開の進行管理や AdMob のレポート確認といった日々の運用タスクと並行していると、リジェクト1件で半日のリズムが崩れることもありました。この「読む・調べる」を機械に任せ、人間は「判断する・直す」に集中する、というのが今回の設計方針です。
通知本文を3層のJSONに構造化する
中核になるのは、リジェクト通知の本文を構造化データへ変換する処理です。Gemini API の構造化出力(response_schema)を使い、通知1通を「指摘単位」に分解します。設計したスキーマは次の3層です。
通知レベル: アプリ名・サブミッションID・返信のみで解決し得るかのフラグ
指摘レベル: ガイドライン番号・審査側の要求・対象(バイナリ/メタデータ/スクリーンショット)
根拠レベル: 通知本文からの原文引用
3層目の「原文引用」を必須にしているのが、運用上いちばん効いているポイントです。要約だけを出力させると、モデルが通知に書かれていない要求を補ってしまったときに気づけません。原文引用を並記させておけば、引用元が本文に存在するかを機械的に検証でき、幻覚の混入を提出前に検出する仕組みになります。
次のコードは実際に使っている処理を整理したものです。何をするコードかを先に言うと、通知本文のテキストを受け取り、指摘単位のリストを含む Pydantic モデルとして返します。
from google import genai
from pydantic import BaseModel
class RejectionItem ( BaseModel ):
guideline: str # 例: "2.3.3"
requirement: str # 審査側が求めていることの要約
evidence: str # 通知本文からの原文引用(必須)
target: str # "binary" / "metadata" / "screenshot" のいずれか
action: str # こちらが取るべき具体的アクション
class RejectionReport ( BaseModel ):
app_name: str
submission_id: str
items: list[RejectionItem]
reply_only_candidate: bool # 修正なしの返信だけで解決し得るか
client = genai.Client()
def parse_rejection (notice_text: str ) -> RejectionReport:
prompt = f """以下は App Store の審査リジェクト通知です。
指摘を1件ずつ分解し、スキーマに従って整理してください。
制約:
- 通知に書かれていない事実を補わないこと
- evidence には必ず通知本文の原文をそのまま引用すること
- 判断に迷う項目は action に「要人間判断」と書くこと
---
{ notice_text }
"""
res = client.models.generate_content(
model = "gemini-3-flash" ,
contents = prompt,
config = {
"response_mime_type" : "application/json" ,
"response_schema" : RejectionReport,
},
)
return res.parsed
def verify_evidence (report: RejectionReport, notice_text: str ) -> list[ str ]:
"""引用が原文に実在するかを検証し、不一致の指摘を返す"""
broken = []
normalized = " " .join(notice_text.split())
for item in report.items:
quoted = " " .join(item.evidence.split())
if quoted[: 80 ] not in normalized:
broken.append(item.guideline)
return broken
なぜこう書くのかについて、2点だけ補足します。まず、モデルは gemini-3-flash で十分でした。通知の分解は読解タスクであって推論タスクではないので、Pro 系を使う必然性がありません。月に数件の処理ならコストは誤差の範囲ですが、レイテンシが短いぶん Flash のほうが運用のテンポに合います。次に、verify_evidence を別関数として分けているのは、検証をモデルの自己申告に任せないためです。引用の実在チェックは Python の文字列照合で済む処理なので、確率的なモデルに頼らず決定的なコードで行います。生成と検証を分離する考え方は、このパイプライン全体を貫く原則になっています。
スキーマ設計で迷ったのは target と reply_only_candidate の2つのフィールドでした。target を「バイナリ/メタデータ/スクリーンショット」の3値に絞ったのは、後続の作業分岐がこの3つで決まるからです。バイナリ指摘なら再ビルドと再提出が必要で、メタデータ指摘なら App Store Connect 上の編集だけで済み、スクリーンショット指摘なら撮影環境の準備から始まります。分類の粒度を細かくしすぎると、モデルの判定ブレが増えるだけで作業分岐には寄与しませんでした。一方 reply_only_candidate は「修正なしの返信だけで解決し得るか」という楽観シナリオのフラグで、これが true の指摘から先に処理すると、待ち時間の長い再ビルドを挟まずに審査を前へ進められる場合があります。もっとも、このフラグはあくまで候補の提示として扱い、返信のみで行くかどうかの最終判断は条文を読んでから自分で下します。
ガイドライン条文との照合で気をつけていること
指摘が構造化できたら、次は該当するガイドライン条文との照合です。ここで最初にやりがちな失敗を先に共有します。導入当初の私のプロンプトは、おおよそ次のようなものでした。
# Before: 条文を渡さない聞き方(再現性が低い)
Guideline 2.3.3 でリジェクトされました。
どう修正すればよいか教えてください。
この聞き方の問題は、モデルが学習時点の記憶からガイドラインを再構成することです。App Store Review Guidelines は年に何度も改定されるため、記憶ベースの回答には古い条文や、存在しない要求が混ざります。実際、トラッキング許可ダイアログの文言について、現行の条文には存在しない「推奨表現」を自信満々に提示されたことがありました。条文の解釈を任せるなら、条文そのものをコンテキストに渡すのが前提になります。
# After: 条文原文を渡し、引用を強制する聞き方
以下に App Store Review Guidelines の該当条文の原文と、
審査からの指摘(構造化済みJSON)を渡します。
タスク:
1. 指摘が条文のどの文に対応するかを、条文から引用して示す
2. 修正で対応する場合の選択肢を、工数の小さい順に挙げる
3. 条文に書かれていないことは「条文外」と明示する
[条文原文をここに貼り付け]
[構造化済みの指摘JSONをここに貼り付け]
After の形式に変えてから、回答の検証にかかる時間が大きく減りました。条文からの引用を強制しているので、回答の正しさを確認する作業が「引用箇所が条文に実在するか」「解釈が引用と整合しているか」の2点に絞られるからです。条文は App Store Review Guidelines から該当セクションを手でコピーして渡しています。ページ全体を自動取得して全文を渡す方法も試しましたが、無関係な条文が混ざると回答の焦点がぼやけるため、該当セクションだけを渡すほうが結果は安定しました。コンテキストは多ければよいわけではない、というのは他のタスクでも繰り返し実感していることです。
ひとつ注意点があります。照合結果はあくまで「解釈の候補」として扱い、最終判断は必ず人間が条文を読んで行います。ガイドラインの解釈を誤ったまま返信すると、審査チームとのやり取りが余計に長引きます。Gemini の役割は判断の材料を高速に揃えることであって、判断そのものではありません。
Resolution Center への返信ドラフトを生成する
修正ではなく説明で解決できると判断した場合は、Resolution Center に返信します。ここは文章の質がそのまま結果に影響する工程で、長く運用する中で私が固めた原則は次の3つです。
事実だけを書く — 機能の動作、実装の根拠、該当画面の場所。推測や感情は書かない
反論しない — 指摘の妥当性を争うのではなく、誤解があれば事実で解消する
短くする — 審査担当者が30秒で読める分量に収める
この原則をプロンプトに織り込んで、英語の返信ドラフトを生成します。
REPLY_RULES = """あなたは個人開発者の代理として App Store の
Resolution Center への返信文を起草します。
原則:
- 事実のみを述べ、推測・感情・反論を含めない
- 各主張には、アプリ内のどこで確認できるかを添える
- 全体を120ワード以内に収める
- 丁寧だが過剰にへりくだらないトーンを保つ
"""
def draft_reply (item_json: str , facts: str ) -> str :
res = client.models.generate_content(
model = "gemini-3-flash" ,
contents = (
f " { REPLY_RULES }\n\n "
f "## 審査からの指摘(構造化済み) \n{ item_json }\n\n "
f "## こちらが確認した事実 \n{ facts }\n "
),
)
return res.text
入力の facts は私が箇条書きで書きます。例えば 2.3.3 の指摘であれば「スクリーンショット3枚目は設定画面で、実機 iPhone 17 Pro Max で撮影したもの」「画面内の文言はアプリの実表示と一致」のような事実列です。ここを手で書くのは手間に見えますが、事実の確認こそ人間にしかできない部分なので、省略しないようにしています。逆に言うと、事実さえ正確に渡せば、それを審査チームに伝わる英語へ組み立てる作業はモデルのほうが速くて安定しています。
返信の効果を実感した例をひとつ挙げると、設定画面の機能説明が「実際の動作と一致しない」と指摘されたケースがありました。実際には一致しており、審査担当者が参照した画面が別のタブだったようだと当たりがついたので、該当機能へ到達する画面遷移を3ステップで書いた120ワードの返信を送ったところ、修正なしで翌営業日に承認されました。長い弁明よりも、確認手順を短く正確に書くことのほうが解決を早める、という手応えを得た出来事です。
生成されたドラフトは必ず読んでから送ります。これまでの運用では、9割はそのまま使える品質で、残り1割は「事実として渡していない補足」をモデルが親切心で足してしまうケースでした。前述の原則どおり、足された部分は機械的に削ります。
複数件が重なったときのトリアージ
冒頭に書いたとおり、私がこの仕組みを作った直接のきっかけは、複数アプリのリジェクトが同じ朝に重なったことでした。構造化データが揃うと、このトリアージが見違えるほど楽になります。判断材料は次の3つです。
解決の速さ — reply_only_candidate が true、または target がメタデータの指摘は当日中に動かせます
影響の大きさ — 段階公開の途中で止まっているアプリは、クラッシュ修正など配信を急ぐ理由があるほど優先度が上がります
期限 — Resolution Center のやり取りを長く放置すると提出物自体が取り下げ扱いになり得るため、放置だけは避けます
実際の運用では、構造化済みのJSONを並べて「メタデータ指摘で当日中に再提出できるもの」から着手し、再ビルドが必要なものは通常の開発サイクルに組み込む、という二列の処理に分けています。以前は届いた順に1件ずつ片付けていたので、軽い指摘が重い指摘の後ろで何日も待たされることがありました。指摘の性質が最初の5分でデータとして見えるようになったことで、着手順を意図して選べるようになったのが、体感としていちばん大きな変化です。
ひとつ実例を挙げます。冒頭の朝の2件のうち、5.1.1 のプライバシー表記の指摘は App Store Connect 上の記載修正だけで対応できる内容だったので、その日の午前中に修正して再提出まで進めました。一方の 2.3.3 はスクリーンショットの再撮影が必要で、対象端末の準備を含めて2日かかる見込みでした。以前の私なら通知を開いた順に重いほうから着手して、軽いほうを寝かせていたはずです。順番を入れ替えただけで、1本のアプリは翌日には審査を通過していました。やっていることは単純な並べ替えですが、並べ替えの判断材料を即座に揃えてくれる仕組みがあるかどうかの差は、件数が重なるほど大きくなります。
過去のリジェクトをNDJSONで蓄積して資産にする
ここまでは「届いたリジェクトへの対応」の話でしたが、運用を続けるうちに、構造化したリジェクトデータそのものが資産になることに気づきました。構造化済みの指摘は、1行1レコードのNDJSONで素直に蓄積できます。
import json
import pathlib
from datetime import date
HISTORY = pathlib.Path( "rejection_history.ndjson" )
def append_history (report: RejectionReport, app_id: str ) -> None :
with HISTORY .open( "a" , encoding = "utf-8" ) as f:
for item in report.items:
f.write(json.dumps({
"date" : date.today().isoformat(),
"app_id" : app_id,
"guideline" : item.guideline,
"target" : item.target,
"requirement" : item.requirement,
"resolution" : "" , # 解決後に追記: "metadata-fix" など
}, ensure_ascii = False ) + " \n " )
直近1年分を集計すると、私の場合は6アプリ合計で9件のリジェクト履歴がありました。並べてみて初めて分かったのは、9件のうち5件がメタデータ起因(スクリーンショット・説明文・プライバシー表記)で、バイナリの機能そのものへの指摘は少数だったことです。つまり、提出前にメタデータを点検するだけで、リジェクトの半分以上は防ぎ得たことになります。アプリごとの傾向も見えます。ライブ壁紙系のアプリはスクリーンショット指摘に偏り、通知機能を持つアプリはプライバシー表記の指摘に偏る、といった具合です。
ファイル形式をデータベースではなくNDJSONにしているのは、件数が高々年間数十件で、grep と目視で全体を見渡せる規模だからです。規模に対して道具を盛らないことも、続けられる仕組みづくりの一部だと考えています。
resolution フィールドを解決後に追記しているのは、後述の提出前チェックで「過去にどう解決したか」をそのまま参照するためです。記録の手間は1件あたり1分もかかりませんが、この1分が次の提出の安心材料になります。手を動かして整えた記録は裏切らないというのは、開発でもアプリ運用でも変わらない感覚です。
提出前のセルフチェックに回す
蓄積した履歴の使い道が、提出前のセルフチェックです。新しいバージョンを提出する前に、これから提出するメタデータと過去の指摘履歴を Gemini に渡し、再発リスクを点検させます。
def preflight_check (app_id: str , metadata_text: str ) -> str :
history_lines = [
line for line in HISTORY .read_text( encoding = "utf-8" ).splitlines()
if json.loads(line)[ "app_id" ] == app_id
]
res = client.models.generate_content(
model = "gemini-3-flash" ,
contents = (
"以下はこのアプリの過去の審査指摘履歴と、"
"今回提出予定のメタデータです。 \n "
"過去の指摘と同種の問題が今回の提出物に含まれていないかを点検し、"
"リスクのある箇所を過去履歴の該当行とともに列挙してください。 \n "
"問題がなければ「該当なし」とだけ答えてください。 \n\n "
f "## 過去の指摘履歴 \n{ chr ( 10 ).join(history_lines) }\n\n "
f "## 今回のメタデータ \n{ metadata_text }\n "
),
)
return res.text
# 提出フローの最後に組み込む
# 1. スクリーンショット差し替えの有無を確認
# 2. preflight_check の出力をレビュー
# 3. 指摘があれば修正してから App Store Connect で提出
渡している metadata_text の中身は、アプリ名・サブタイトル・説明文・キーワード・プロモーションテキスト・スクリーンショットの構成メモ(何枚目がどの画面か)・プライバシー関連の記載のセットです。スクリーンショットそのものを画像で渡す方法も試しましたが、過去履歴との突き合わせという目的に対しては、撮影内容を一行ずつテキストにしたメモのほうが指摘の再現性が高く、点検も速く済みました。画像理解を使うのは「画面内の文言と説明文の不一致を探す」のような画像でなければ分からない確認に限定し、履歴照合はテキストで行う、という役割分担に落ち着いています。
このチェックを入れてから、過去の指摘と同種の理由でのリジェクトは発生していません。導入後に提出したアップデートは6アプリ合計で20回ほどですから、まだ統計的に意味のある数とは言えませんが、少なくとも「前も同じ理由で落ちたのに」という最も悔しいパターンは防げています。提出前の点検が30秒で終わる安心感は、数字以上に大きいものでした。
時間の面では、リジェクト対応1件あたりの所要時間は平均90分から30分前後まで短縮されました。内訳を見ると、削れたのはやはり「読む・調べる」の前半部分で、返信文の最終確認と修正作業そのものにかける時間はほぼ変わっていません。判断と手作業はそのままに、その手前の準備だけが速くなった、というのが正確な表現になります。
処理コストとモデル選定の実測
導入を検討する方が気になるであろうコストとレイテンシについて、実測値を共有します。リジェクト通知1通の本文は、私のケースではおおよそ1,500〜3,000トークンでした。構造化・条文照合・返信ドラフトの3工程をすべて回しても、1件あたりの合計は入出力あわせて1万トークンに届きません。gemini-3-flash の料金では1件あたり1円未満で、月に数件の処理なら、コストは事実上考えなくてよい水準です。API のレート制限についても、この用途では1分間に数リクエストしか発行しないため、無料枠の範囲でも詰まることはありませんでした。
モデル選定では、導入時に gemini-3-flash と Pro 系を同じ通知10通で比較しています。構造化の精度はどちらもほぼ同等で、差が出たのはレイテンシだけでした。Flash は1通あたり3秒前後、Pro 系は10秒を超えることがあり、「届いた通知をその場でさっと分解する」という使い方には Flash のテンポが合います。唯一 Pro 系が優位だったのは、条文照合で複数の条文にまたがる解釈を整理させたときの文章の構成力でしたが、その差は最終判断を人間が行う前提では決め手になりませんでした。私はこの手の運用ツールでは、まず Flash で組んでみて、精度に不満が出た工程だけ Pro 系に差し替える順番をお勧めします。最初から大きいモデルで組むと、コストよりもレイテンシの面で日常の道具として使わなくなっていく、というのが他の自動化でも繰り返してきた失敗でした。
なお、構造化出力の安定性という点では、スキーマの各フィールドにコメントで具体例を添えること、列挙値は3〜4個に絞ることの2つが効きました。特に target のような分類フィールドは、選択肢を増やすほど判定が割れます。スキーマは「モデルへの指示書」でもあると考えて、人間のレビュー観点と同じ粒度に揃えるのが安定への近道です。
運用して見えた限界と、人が判断すべき境界
仕組みとしてはうまく回っていますが、限界もはっきりしています。導入を検討される方のために、率直に書いておきます。
第一に、ガイドラインの解釈そのものは任せられません。条文を渡せば照合の精度は上がりますが、条文と条文のあいだにある「審査の運用実態」までは、モデルは知りません。例えば同じ 4.3 系の指摘でも、何が決め手で通るかは時期によって肌感覚が変わります。ここは開発者コミュニティの情報と自分の経験で補う領域です。
第二に、審査チームとのやり取りは交渉ではなく対話なので、テンプレートの綺麗な返信より、対象アプリの具体的な事実が書かれた返信のほうが明らかに早く解決します。生成ドラフトをそのまま量産的に使い回すと、かえって遠回りになると感じています。私がドラフト生成の入力に「手書きの事実リスト」を必須にしているのは、この理由からです。
第三に、リジェクト通知には開発中の機能やアプリの内部情報への言及が含まれることがあります。API に渡す前に、通知本文に含めたくない情報がないかを一度確認する習慣は残しています。機械化した後も、入口と出口に人間の目を置くという構えは崩していません。
第四に、この種の半自動化は「作って終わり」になりません。審査通知の文面形式は予告なく変わりますし、ガイドラインの条文構成も改定のたびにずれます。私は四半期に一度、直近の通知を構造化に通した結果を見直して、スキーマの説明文やプロンプトの制約を微調整しています。頻度として大きな負担ではありませんが、メンテナンスの存在を前提に、コードはなるべく小さく保つようにしています。パイプラインを欲張って太らせるほど、形式変化への追従が億劫になるからです。
最後に、この仕組みは Google Play の事前審査の指摘にもほぼそのまま流用できています。スキーマの guideline フィールドをポリシー名に読み替えるだけで、構造化と履歴蓄積のコードは共通で動きます。ストアごとに対応フローを別管理していた頃と比べて、頭の切り替えコストが減ったのも副次的な収穫でした。
リジェクトは個人開発を続ける限り避けられないイベントです。だからこそ、1件ごとに消耗するのではなく、対応の手順を仕組みにして、記録を次の提出に活かす循環を作る価値があります。まずは過去のリジェクト通知を1通、構造化スキーマに通してみてください。自分のアプリの「落ちやすい箇所」が データとして見えてくるはずです。同じように複数アプリの審査対応に追われている方の参考になれば幸いです。