朝の 7:00 に Slack を開くと、4 本の壁紙アプリの前日の売上・AdMob 収益・サブスク継続率が 1 枚のサマリで届いている — そんな状態にようやくたどり着くまでに、約 8 週間を費やしました。
2014 年から個人で iOS アプリを開発し、累計 5,000 万ダウンロードを超えた今でも、毎朝のように App Store Connect と AdMob と Firebase の管理画面を順番に開いて、数字を Google スプレッドシートに転記する作業を続けていました。地味で間違えやすい仕事です。土日も忘れずに開かないと月曜の朝に 3 日分の作業が積み上がります。
このルーチンを完全に手放すために、Claude Code に「毎朝 7:00 JST に App Store Connect API と AdMob Reporting API を叩いて、4 アプリ分を JST 基準で結合してダッシュボードに書き戻す Python パイプライン」を組んでもらうことにしました。書き上げるだけなら 1 日でいけますが、本番運用で安定するまでに 8 週間かかりました。途中で詰まった具体的な落とし穴と、Claude Code がどこで効いたかを残しておきます。同じように複数アプリを個人で運営している方に届けば嬉しいです。
なぜ「公式ダッシュボードを見ればいい」では駄目だったのか
App Store Connect も AdMob も Firebase も、立派なダッシュボードを提供しています。それでも自前パイプラインを組んだ理由は 3 つあります。
1 つ目は タイムゾーンの不一致 です。App Store Connect のレポートは US Pacific 基準 (UTC-8) で日次集計され、AdMob はアカウント設定によりますが私の場合は Pacific Time (UTC-8) のまま運用されており、Firebase Analytics はプロジェクト設定で JST にしているものとそうでないものが混在していました。「2026 年 5 月 22 日の売上」を比較しようとすると、3 つのダッシュボードでカバーする時間帯が微妙にずれます。個人で 4 本のアプリを並行運用していると、このズレを頭の中で補正し続けるのは現実的ではありませんでした。
2 つ目は 過去 90 日しか自由に遡れない ことです。AdMob のレポートは GUI 上で過去 2 年分まで取れますが、毎週同じ集計を取り直すなら自前で蓄積したほうが圧倒的に早いです。8 週間運用したいま、180 日分の日次データが手元の SQLite に溜まっており、新しい施策を打ったときに「過去 6 ヶ月の同曜日比」をその場で出せるようになりました。
3 つ目は 複数アプリを横断した「気づき」の遅延 です。4 本のアプリの売上・eCPM・継続率を別々に眺めていると、「壁紙アプリ A だけ AdMob 単価が金曜から急落している」のような小さなシグナルを見落とします。1 枚に統合したダッシュボードがあれば、土日のうちに Slack 通知で気づけます。実際にこの 8 週間で、AdMob のリワード広告ユニットの 1 つで eCPM が水曜から木曜にかけて 38% 下がっていたことを朝の Slack 通知で発見し、別のメディエーション設定に切り替えて回復させた経験があります。
全体構成 — 何を取って、どこに置くか
最初に Claude Code と決めたのは、保存先と粒度です。個人開発の規模感を考えると、Cloud SQL や BigQuery を即採用するのは過剰だと判断しました。
[毎朝 06:30 JST] [毎朝 07:00 JST]
asc_fetcher.py ──┐ ┌──> dashboard_sync.py
admob_fetcher.py ─┤ ──> raw_*.csv (S3) ──> │ (Google Sheets書き込み)
fbase_fetcher.py ─┘ ├──> slack_digest.py
│ (前日比 + 異常検知)
merger.py └──> revenue.db (SQLite)
│
└──> daily_summary テーブル
S3 を中間置き場にしているのは、API 呼び出しと集計を完全に分離するためです。API 側のレート制限や認証エラーで失敗しても、生 CSV さえ残っていれば集計層は何度でも再実行できます。Claude Code に「失敗ポイントを後で原因切り分けしやすいように、API 取得・整形・配信を 3 段階に分けたい」と最初に伝えたのが、後から効いてきました。
集計は SQLite で十分でした。8 週分でも 200 KB 程度です。手元のターミナルから sqlite3 で気軽に叩けるのが、データの粒度を見直したいときに便利です。
App Store Connect API の認証で 3 日溶かした話
最初の壁は App Store Connect API の JWT 認証でした。Apple Developer の Users and Access ページから API キー (.p8 ファイル) を発行し、Key ID と Issuer ID とともに JWT を組み立てます。
Claude Code に最初に書いてもらったコードはこうでした。
# ❌ 動かなかった最初のコード
import jwt
import time
def make_token (key_path: str , key_id: str , issuer_id: str ) -> str :
with open (key_path, "rb" ) as f:
private_key = f.read()
headers = { "alg" : "ES256" , "kid" : key_id, "typ" : "JWT" }
payload = {
"iss" : issuer_id,
"iat" : int (time.time()),
"exp" : int (time.time()) + 1200 ,
"aud" : "appstoreconnect-v1" ,
}
return jwt.encode(payload, private_key, algorithm = "ES256" , headers = headers)
これで叩くと 401 Unauthorized が返ります。原因を Claude Code と一緒に切り分けていったところ、3 つの落とし穴に当たっていました。
1 つ目は exp を iat + 1200 ではなく現在時刻基準にしないと、デバッグ中に時計が動いて期限切れに見える ことです。exp = now + 1200 ではなく、now = int(time.time()); exp = now + 1200 のように同じスナップショットを使う必要がありました。微差ですが、コードが速い言語ほど踏みやすい罠です。
2 つ目は scope を payload に含めると一部エンドポイントで弾かれる ことです。Apple 公式サンプルには scope フィールドの例がありますが、Sales/Trends Reports エンドポイントでは付けないほうが安定しました。
3 つ目は決定打で、JWT ライブラリが ECDSA の DER 形式と raw 形式を取り違える ケースでした。PyJWT の場合は問題なかったのですが、Claude Code が最初に組んでくれた手動実装では cryptography で r s を連結した raw 64 バイトを返していて、Apple 側が DER を期待していたため弾かれていました。これは公式ドキュメントには明示されておらず、ライブラリの実装を読まないと気づきません。最終的に動いたのは以下です。
# ✅ 動いた最終形
import jwt
import time
def make_token (key_path: str , key_id: str , issuer_id: str ) -> str :
with open (key_path, "rb" ) as f:
private_key = f.read()
now = int (time.time())
payload = {
"iss" : issuer_id,
"iat" : now,
"exp" : now + 1200 ,
"aud" : "appstoreconnect-v1" ,
}
return jwt.encode(
payload,
private_key,
algorithm = "ES256" ,
headers = { "kid" : key_id, "typ" : "JWT" },
)
PyJWT のバージョンは 2.8 以上を推奨します。これで安定しました。3 日溶かしましたが、後で同じ罠を踏まないようにスクリプトの先頭に「JWT は PyJWT 2.8+ で生成すること。手動 ES256 実装は DER 形式必須」とコメントを残しておきました。Claude Code のおかげでハマりログが残るのが、こうした個人運用で一番ありがたい部分です。
Sales Report の取得 — gzip と空レポート日に注意する
JWT さえ通れば、Sales Report 取得は素直です。エンドポイントは https://api.appstoreconnect.apple.com/v1/salesReports で、filter[frequency]=DAILY と filter[reportDate]=YYYY-MM-DD を渡します。レスポンスは gzip 圧縮された TSV で、Content-Type は application/a-gzip です。
ここで詰まったのが 2 点ありました。
# 落とし穴 1: gzip を展開せずそのまま CSV パーサに渡すと文字化けする
# 落とし穴 2: その日のセールスがゼロだと 404 ではなく 200 で本文が空
def fetch_daily_sales (token: str , vendor_id: str , report_date: str ) -> bytes | None :
url = "https://api.appstoreconnect.apple.com/v1/salesReports"
params = {
"filter[frequency]" : "DAILY" ,
"filter[reportDate]" : report_date,
"filter[reportType]" : "SALES" ,
"filter[reportSubType]" : "SUMMARY" ,
"filter[vendorNumber]" : vendor_id,
"filter[version]" : "1_0" ,
}
headers = { "Authorization" : f "Bearer { token } " }
r = requests.get(url, params = params, headers = headers, timeout = 30 )
if r.status_code == 404 :
# Apple の集計が未確定 (前日朝に呼ぶと UTC-8 でまだ未確定の場合がある)
return None
r.raise_for_status()
# gzip 展開
return gzip.decompress(r.content)
特に厄介なのは 「ゼロセールスの日でも 200 が返るが TSV のヘッダ行だけ」 というパターンです。最初は気づかず、空 TSV を集計層に流して「売上 0 円」と記録していたのですが、ある日 Slack 通知で「壁紙アプリ B の売上が 0 円」が出続けることに気づきました。実際は祝日でレポート確定が遅れていただけで、TSV のヘッダ行しかない状態でした。Claude Code に「ヘッダ行しかない gzip TSV を None 扱いで再キューに戻す」処理を追加してもらい、それ以降は誤通知が消えました。
リトライ戦略は以下のシンプルな指数バックオフで十分でした。
def fetch_with_retry (fn, * args, max_retry: int = 4 ) -> bytes | None :
for attempt in range (max_retry):
try :
result = fn( * args)
if result is not None :
# ヘッダ行のみの空レポートを除外
lines = result.decode( "utf-8" ).splitlines()
if len (lines) <= 1 :
return None
return result
except requests.HTTPError as e:
if e.response.status_code in ( 429 , 500 , 502 , 503 ):
wait = 2 ** attempt
time.sleep(wait)
continue
raise
return None
レート制限は ASC の場合、私のキーで 30 req/min 程度までは余裕で叩けました。4 アプリ × 1 日 1 回なので問題になりません。
AdMob Reporting API の罠 — Locale と日付フィルタ
AdMob 側は Google API なので OAuth2 のサービスアカウント認証で済みます。google-api-python-client を使うと比較的素直に書けます。
from googleapiclient.discovery import build
from google.oauth2 import service_account
SCOPES = [ "https://www.googleapis.com/auth/admob.readonly" ]
def get_admob_client (sa_path: str ):
creds = service_account.Credentials.from_service_account_file(
sa_path, scopes = SCOPES
)
return build( "admob" , "v1beta" , credentials = creds)
詰まったのは レポート定義の localizationSettings です。最初は省略していたところ、AD_REQUEST などの数値がうまく取れないケースがありました。明示的に currencyCode: "JPY" と languageCode: "ja" を入れることで、収益が JPY で返るようになりました。USD のままだと社内集計のためにレート換算が必要になり、結合が面倒になります。
report_spec = {
"dateRange" : {
"startDate" : { "year" : 2026 , "month" : 5 , "day" : 22 },
"endDate" : { "year" : 2026 , "month" : 5 , "day" : 22 },
},
"dimensions" : [ "DATE" , "APP" , "AD_UNIT" , "COUNTRY" , "FORMAT" ],
"metrics" : [
"AD_REQUESTS" , "MATCHED_REQUESTS" ,
"IMPRESSIONS" , "CLICKS" ,
"ESTIMATED_EARNINGS" , # JPY で返る
"IMPRESSION_CTR" ,
],
"localizationSettings" : {
"currencyCode" : "JPY" ,
"languageCode" : "ja" ,
},
"timeZone" : "Asia/Tokyo" ,
}
timeZone を Asia/Tokyo にしているのが大事なポイントです。AdMob はデフォルトでアカウント設定のタイムゾーンを使いますが、レポート単位で Asia/Tokyo を指定できます。これで App Store Connect 側を Pacific Time から JST に補正する処理と、AdMob 側のタイムゾーン補正が、それぞれ別個に動くことになります。
「Pacific Time の 5/22 売上」と「JST の 5/22 AdMob 収益」をそのまま結合すると、結局 17 時間ずれます。私のパイプラインでは ASC のレポートを取り込むときに reportDate から 17 時間進めて JST の境界に合わせる 前処理を入れています。
-- JST 基準の日次サマリ
CREATE TABLE daily_summary (
jst_date TEXT NOT NULL ,
app_id TEXT NOT NULL ,
asc_revenue_jpy INTEGER , -- App Store Connect (PT を JST に補正済み)
admob_earnings_jpy INTEGER , -- AdMob (元から JST 指定)
admob_impressions INTEGER ,
admob_ecpm_jpy REAL ,
subscriber_count INTEGER ,
PRIMARY KEY (jst_date, app_id)
);
完全な補正ではありませんが、「日次変動を JST 基準で人間が解釈する」目的では十分でした。会計用ではなく運用判断用なので、月次の合計が ASC ダッシュボードと 1〜2% 差があっても問題にはなりません。
通知設計 — 「前日比 25%」「3 日連続で曜日同期 −15%」を Slack に流す
8 週間運用していて気づいたのは、「異常を見つけるしきい値」を保守的にし過ぎると Slack 通知が空振りして読み飛ばす癖がつくことです。逆に緩すぎると本当に効いたとき気づきません。最終的に落ち着いたのは以下のルールでした。
前日比 25% 以上の変動 で個別アプリの売上・AdMob 収益を通知
3 日連続で「同曜日比 -15%」 が出たら eCPM 単価の構造変化として警告
サブスク継続率 (Day 7) が直近 14 日中央値から ±3 ポイントずれたら通知
AdMob のメディエーション単価が 1 ad unit で 30% 以上低下 したら個別通知
App Store Connect API の 401/403 が連続したら認証エラーとして即通知
Sales Report の空応答が 2 日連続 で同じアプリに発生したら集計遅延として通知
ルール 6 は前述のヘッダ行のみ問題に対応するもので、これがあると Apple 側の集計遅延と本物の売上 0 円を区別できます。
通知 SQL の一例はこうです。
-- 前日比 25% 以上の変動を抽出
WITH yesterday AS (
SELECT app_id, asc_revenue_jpy, admob_earnings_jpy
FROM daily_summary
WHERE jst_date = date ( 'now' , '-1 day' , 'localtime' )
),
day_before AS (
SELECT app_id, asc_revenue_jpy AS prev_asc, admob_earnings_jpy AS prev_admob
FROM daily_summary
WHERE jst_date = date ( 'now' , '-2 day' , 'localtime' )
)
SELECT
y . app_id ,
y . asc_revenue_jpy , d . prev_asc ,
ROUND ( CAST ( y . asc_revenue_jpy AS REAL ) / NULLIF ( d . prev_asc , 0 ) - 1 . 0 , 3 ) AS asc_pct,
y . admob_earnings_jpy , d . prev_admob ,
ROUND ( CAST ( y . admob_earnings_jpy AS REAL ) / NULLIF ( d . prev_admob , 0 ) - 1 . 0 , 3 ) AS admob_pct
FROM yesterday y JOIN day_before d ON y . app_id = d . app_id
WHERE ABS ( CAST ( y . asc_revenue_jpy AS REAL ) / NULLIF ( d . prev_asc , 0 ) - 1 . 0 ) >= 0 . 25
OR ABS ( CAST ( y . admob_earnings_jpy AS REAL ) / NULLIF ( d . prev_admob , 0 ) - 1 . 0 ) >= 0 . 25 ;
NULLIF(prev, 0) で前日が 0 円のときの ZeroDivision を回避しているのが地味なポイントです。これを忘れると新規アプリリリース直後の月曜朝に通知ジョブが落ちます。
8 週間で見えた数字 — eCPM 14.3% 改善とサブスク継続率の構造
このパイプラインを 8 週運用してきて、運用判断に直結した数字を 3 つ挙げておきます。個別のアプリ名は伏せますが、4 アプリの平均的な数字です。
1 つ目は AdMob リワード広告の eCPM が +14.3% 改善 したことです。日次サマリで「特定の ad unit だけ eCPM が金曜から急落している」のを 5 月第 2 週に発見し、その週の週末に メディエーションウォーターフォールの順序を入れ替えた結果、翌週から戻りました。手動運用していたら金曜から月曜まで気づけなかった可能性が高いです。改善幅は 4 アプリ平均で +14.3%、最大のアプリで +21.8% でした。
2 つ目は Day 7 サブスク継続率が平日と週末で構造的に差がある ことが見えたことです。週末に新規登録した人の方が Day 7 継続率が平均 4.2 ポイント高い傾向がありました。これは「週末は試しに入って続ける」、平日は「広告につられて入って忘れる」という挙動の差だろうと推測しています。この知見は AdMob のリワード広告のサブスク誘導動線を、平日と週末で別クリエイティブにする実験につながりました。
3 つ目は 個人開発でも 4 アプリの収益サマリを毎朝見る習慣 がついたことです。これが一番大きい変化でした。手動運用の頃は週末をまたぐと月曜の朝に 3 日分の数字を見るのが面倒で、結局火曜まで放置することがありました。Slack に勝手に届くと、コーヒーを淹れながら 30 秒で確認できます。1 つの数字がおかしいときだけ詳細を見に行けば良いので、運用の心理的負荷が大きく下がりました。
Claude Code がどこで効いたか — コードよりログ整理
8 週間を振り返って、Claude Code の貢献が最も大きかったのは「コードを書く速さ」ではなく、詰まったログを一緒に読み解いて、原因のメモを残す部分 でした。
JWT 認証の DER/raw 形式問題、AdMob の localizationSettings 省略時の挙動、Sales Report のヘッダ行のみパターン — これらは公式ドキュメントには明示されていない暗黙の仕様です。Claude Code に「この 401 はなぜ起きていそうか」と聞いて、可能性を 3 つほど挙げてもらい、私が再現スクリプトで切り分けるという往復で、1 つあたり半日〜1 日で潰せました。
特にありがたかったのは、再現スクリプトを書くときに 「最小再現コードを別ファイルに切り出して、ログ出力を全て JSON 1 行に圧縮する」 ような癖を Claude Code が持っていることです。私が個別に書くと、ついログを print で散らしてしまうのですが、Claude Code が書く再現コードは、後から jq でフィルタしやすい構造化ログになっており、トラブルシューティングが圧倒的に速くなりました。
両家の祖父が宮大工で、組み上げた木組みは何十年と保つことを近くで見てきました。コードも同じで、急いで動かしただけのコードは数週間で壊れます。8 週運用してきて言えるのは、本番運用に乗るパイプラインは「動いた」から「壊れない」までの距離が長いということです。Claude Code は、その距離を縮めてくれる相棒として、個人開発の規模感に非常にフィットしています。
まず週末に試せる最小構成
ゼロから 8 週運用まで再現するのは大変なので、最初の週末で動かせる最小構成を 1 つだけ書いておきます。
# 1) 仮想環境を作る
python3 -m venv .venv && source .venv/bin/activate
pip install pyjwt cryptography requests google-api-python-client \
google-auth google-auth-httplib2
# 2) ディレクトリ
mkdir -p revenue_pipeline/{secrets,raw,scripts,db}
# 3) ASC キーと AdMob サービスアカウントを secrets/ に置く
# 4) Claude Code に依頼するプロンプト例
# 「revenue_pipeline/scripts/asc_fetcher.py を作って。
# - JWT 認証は PyJWT 2.8 で
# - Sales Report (DAILY/SUMMARY) を 7 日分取得
# - gzip 展開して raw/asc_YYYY-MM-DD.tsv に保存
# - 200 でヘッダ行のみは None 扱いで再キュー
# - 失敗時は指数バックオフで 4 回まで再試行」
このプロンプトをそのまま Claude Code に投げれば、80% 完成のスクリプトが返ってきます。残りの 20% を 1〜2 時間かけて自分の環境に合わせれば、最初の 1 アプリ分は週末で動きます。複数アプリに広げるのと SQLite に集約するのは翌週で十分です。
同じように複数アプリを個人で運営している開発者にとって、毎朝の数字確認は「やらなければいけない地味な仕事」になりがちです。これを Claude Code に渡せると、本来やりたい新機能開発や作品制作に使える時間が増えます。私自身、毎朝 15 分かかっていた集計が完全になくなり、その時間でアプリのカスタマー対応や次のアプリ企画に手が回せるようになりました。
このパイプラインのコードはまだ社内コードなので公開はしていませんが、同じ構造で動かしている個人開発者の方の参考になれば嬉しいです。失敗の記録と判断ルールの 6 つが、特に役立つはずです。