iOS/Android アプリを数本リリースして、月末に収益を把握しようと思った瞬間に気づきます。AdMob は AdMob の管理画面、App Store Connect は App Store Connect の画面、Google Play Console は Google Play Console の画面 — 3つのダッシュボードを行ったり来たりして、Excel に数字を書き写す作業が待っているのです。
私も長らくこの手作業に時間を吸い取られていました。「今月いくら稼いだか」を知るのに30分かかる状態を、Claude Code に相談しながら1つのダッシュボードに統合した過程で得た知見を、本番運用できるレベルでまとめた記事です。
この記事は「API ドキュメントを読めば分かる話」ではありません。3つのプラットフォームそれぞれに異なる認証方式・データフォーマット・レート制限の癖があり、ドキュメントだけでは見えない実装の落とし穴が多数あります 。私が実際に詰まった場所と、Claude Code にどう相談して抜け出したかを、コードとともに共有します。
このダッシュボードで解決したい問題
最初に、何を作るのかを明確にしておきましょう。設計は目的に従うので、ここを曖昧にすると後で要件が膨らみます。
3プラットフォームの日次売上・ダウンロード数・ARPU を1画面で確認したい
過去30日のグラフで売上トレンド を把握したい
新規リリースしたアプリの立ち上がり速度 を他アプリと比較したい
毎朝 Slack に昨日の売上サマリ が自動投稿されるようにしたい
データは Cloudflare Workers 上の KV に格納し、無料枠で運用 したい
最後の条件が地味に効いてきます。個人開発の収益ダッシュボードに月数千円のインフラ費を払うのは本末転倒なので、Cloudflare Workers の無料枠(10万リクエスト/日)+ KV 無料枠 に収まる設計を最初から考えます。
なぜ Claude Code と組むと早いのか
結論から言うと、3プラットフォームの API 統合は 公式ドキュメントを読むだけでは実装できない箇所が多い からです。
App Store Connect の Sales Reports API は、レスポンスが gzip 圧縮された TSV ファイル(Tab 区切り)で返ってきます。Google Play Console の Reporting API は、認証がサービスアカウントの OAuth2 で、月末日の翌営業日にしか確定データが揃いません。AdMob は Network Report API で柔軟にクエリできますが、アプリ単位の分解には複雑な filter 指定が必要です。
Claude Code が優秀なのは、これらの癖を一度教えれば、以後そのプロジェクト内では忘れない ことです。CLAUDE.md に「App Store Connect API のレスポンスは gzip 圧縮 TSV」と書いておけば、その後のコードはすべてその前提で書かれます。プラットフォームごとにパーサーを何度も書き直す時間が減るのは、地味に大きな生産性向上です。
技術スタックの選定
以下の構成で実装します。各選択の理由も添えておきます。
Cloudflare Workers : Cron Triggers で日次バッチ実行。起動コストゼロで無料枠が広い
Cloudflare KV : 日次集計データの永続化。prefix list で日付範囲クエリが可能
Hono : Workers で動くフレームワーク。型安全で軽量
Recharts : ダッシュボード側のグラフ描画。Next.js 側で動くので Workers には含めない
Next.js + Cloudflare Pages : ダッシュボード UI。KV に API 経由でアクセス
なお、データ集計と UI は別プロジェクト として分離しています。理由は、バッチ処理の Worker と UI Worker で求められる特性が違うからです。バッチは Cron で1日数回動けばよく、UI はユーザーアクセスのたびに高速応答が必要です。分離しておくと、UI の改修がバッチを壊さず安全に進められます。
Step 1: App Store Connect API の認証と Sales Reports 取得
最初の壁はここです。App Store Connect API の認証は、他の Google 系 API とはまったく違うので、慣れていないと詰まります。
P8 秘密鍵で JWT を自作する
App Store Connect は Bearer トークン(JWT)を自分で署名して作る 方式です。Apple Developer サイトから P8 秘密鍵をダウンロードし、ES256 アルゴリズムで JWT を署名します。
// src/auth/appstore.ts
import { SignJWT, importPKCS8 } from 'jose' ;
interface AppStoreAuthConfig {
issuerId : string ; // App Store Connect の Issuer ID
keyId : string ; // P8 ファイル名の "AuthKey_XXX.p8" の XXX 部分
privateKey : string ; // P8 ファイルの中身(PEM 形式)
}
/**
* App Store Connect API 用の JWT を生成します。
* 有効期限は最大20分(Apple の仕様)。実運用では毎回生成するよりキャッシュ推奨。
*/
export async function generateAppStoreJWT ( config : AppStoreAuthConfig ) : Promise < string > {
const privateKey = await importPKCS8 (config.privateKey, 'ES256' );
const jwt = await new SignJWT ({
iss: config.issuerId,
aud: 'appstoreconnect-v1' ,
})
. setProtectedHeader ({
alg: 'ES256' ,
kid: config.keyId,
typ: 'JWT' ,
})
. setIssuedAt ()
. setExpirationTime ( '18m' ) // 20分ぎりぎりより少し短く
. sign (privateKey);
return jwt;
}
なぜ 18 分にするのか : Apple の仕様で JWT は最大20分有効ですが、リクエスト送信中にタイムアウトすると 401 エラーで詰まります。18分にしておくと、バッチ処理の途中でトークン失効が起きるリスクを避けられます。ここは実運用で「謎の 401 が稀に出る」原因になりやすいポイントです。
gzip 圧縮 TSV のパース
JWT を作ったら、Sales Reports API を叩きます。ここで初見殺しの仕様が登場します。
// src/fetchers/appstore-sales.ts
import { generateAppStoreJWT } from '../auth/appstore' ;
import { gunzipSync } from 'node:zlib' ;
export interface AppStoreSalesRow {
appleId : string ;
title : string ;
units : number ;
developerProceeds : number ; // 開発者手取り (USD)
date : string ;
}
/**
* 指定日の Sales Report を取得します。
* レスポンスは gzip 圧縮された TSV で返ってくる点に注意。
*/
export async function fetchAppStoreSales (
jwt : string ,
vendorNumber : string ,
reportDate : string , // YYYY-MM-DD
) : Promise < AppStoreSalesRow []> {
const params = new URLSearchParams ({
'filter[frequency]' : 'DAILY' ,
'filter[reportType]' : 'SALES' ,
'filter[reportSubType]' : 'SUMMARY' ,
'filter[vendorNumber]' : vendorNumber,
'filter[reportDate]' : reportDate,
'filter[version]' : '1_1' ,
});
const res = await fetch (
`https://api.appstoreconnect.apple.com/v1/salesReports?${ params }` ,
{
headers: {
Authorization: `Bearer ${ jwt }` ,
Accept: 'application/a-gzip' , // この Accept ヘッダが必須
},
},
);
if (res.status === 404 ) {
// 当日データがまだ確定していない場合は 404 で返る
return [];
}
if (\ ! res.ok) {
throw new Error ( `App Store Connect error: ${ res . status } ${ await res . text () }` );
}
// gzip 解凍して TSV をパース
const compressed = new Uint8Array ( await res. arrayBuffer ());
const decompressed = gunzipSync (compressed). toString ( 'utf-8' );
return parseTSV (decompressed);
}
function parseTSV ( tsv : string ) : AppStoreSalesRow [] {
const lines = tsv. split ( ' \n ' ). filter (Boolean);
const headers = lines[ 0 ]. split ( ' \t ' );
const rows = lines. slice ( 1 );
const idxAppleId = headers. indexOf ( 'Apple Identifier' );
const idxTitle = headers. indexOf ( 'Title' );
const idxUnits = headers. indexOf ( 'Units' );
const idxProceeds = headers. indexOf ( 'Developer Proceeds' );
const idxDate = headers. indexOf ( 'Begin Date' );
return rows
. map (( row ) => {
const cols = row. split ( ' \t ' );
return {
appleId: cols[idxAppleId],
title: cols[idxTitle],
units: Number (cols[idxUnits] || 0 ),
developerProceeds: Number (cols[idxProceeds] || 0 ),
date: cols[idxDate],
};
})
. filter (( r ) => r.units > 0 );
}
詰まりやすいポイント : Accept: 'application/a-gzip' ヘッダを付けないと、404 を返されます。ドキュメントには書いてあるのですが、サンプルコードを見ないと気づきにくい仕様です。私もここで30分溶かしました。
また、当日〜前日のデータは 404 で返ることがあります。これはエラーではなく「データ未確定」のサインなので、正常系として扱う必要があります。エラー扱いにするとバッチが毎朝失敗することになります。
Step 2: AdMob Network Report API
AdMob は Google OAuth2 サービスアカウント認証を使います。ここは App Store Connect より素直ですが、クエリの組み立て方に癖があります。
// src/fetchers/admob-report.ts
import { GoogleAuth } from 'google-auth-library' ;
export interface AdMobRow {
appId : string ;
appName : string ;
date : string ;
impressions : number ;
earnings : number ;
ecpm : number ;
}
/**
* AdMob の日次レポートを取得します。
* POST で JSON クエリを送る点が REST っぽくないので注意。
*/
export async function fetchAdMobReport (
serviceAccountKeyJson : string ,
accountId : string , // "pub-XXXXXXXXXX"
startDate : string ,
endDate : string ,
) : Promise < AdMobRow []> {
const auth = new GoogleAuth ({
credentials: JSON . parse (serviceAccountKeyJson),
scopes: [ 'https://www.googleapis.com/auth/admob.readonly' ],
});
const client = await auth. getClient ();
const token = ( await client. getAccessToken ()).token;
const body = {
reportSpec: {
dateRange: {
startDate: parseDate (startDate),
endDate: parseDate (endDate),
},
dimensions: [ 'DATE' , 'APP' , 'APP_NAME' ],
metrics: [ 'IMPRESSIONS' , 'ESTIMATED_EARNINGS' , 'IMPRESSION_RPM' ],
dimensionFilters: [],
sortConditions: [
{ dimension: 'DATE' , order: 'ASCENDING' },
],
},
};
const res = await fetch (
`https://admob.googleapis.com/v1/accounts/${ accountId }/networkReport:generate` ,
{
method: 'POST' ,
headers: {
Authorization: `Bearer ${ token }` ,
'Content-Type' : 'application/json' ,
},
body: JSON . stringify (body),
},
);
if (\ ! res.ok) {
throw new Error ( `AdMob error: ${ res . status } ${ await res . text () }` );
}
const data = await res. json < any []>();
return data
. filter (( item ) => item.row)
. map (( item ) => ({
appId: item.row.dimensionValues. APP .value,
appName: item.row.dimensionValues. APP_NAME .value,
date: formatDate (item.row.dimensionValues. DATE .value),
impressions: Number (item.row.metricValues. IMPRESSIONS .integerValue || 0 ),
// microsAmount は 100万倍の整数で入ってくる
earnings: Number (item.row.metricValues. ESTIMATED_EARNINGS .microsAmount || 0 ) / 1_000_000 ,
ecpm: Number (item.row.metricValues. IMPRESSION_RPM .microsAmount || 0 ) / 1_000_000 ,
}));
}
function parseDate ( ymd : string ) : { year : number ; month : number ; day : number } {
const [ y , m , d ] = ymd. split ( '-' ). map (Number);
return { year: y, month: m, day: d };
}
function formatDate ( d : { year : number ; month : number ; day : number }) : string {
return `${ d . year }-${ String ( d . month ). padStart ( 2 , '0' ) }-${ String ( d . day ). padStart ( 2 , '0' ) }` ;
}
詰まりやすいポイント : AdMob の金額は microsAmount フィールドに 100万倍の整数 で入っています。円やドルに直すには 1,000,000 で割る必要があります。これを見落とすと売上が1億倍に膨らんで、一瞬ぬか喜びします(経験談)。
また、レスポンスの構造が珍しく、header・row・footer の3種類の要素が同じ配列に混在します。row だけフィルタして処理する必要があります。
Step 3: Google Play Console の売上データ取得
Google Play の売上データは、Cloud Storage バケットに CSV としてエクスポートされる方式です。Reporting API で直接取る方法と、GCS から CSV を取る方法があります。確定済みの売上額は GCS 経由の方が確実 です。
// src/fetchers/playstore-sales.ts
import { GoogleAuth } from 'google-auth-library' ;
import Papa from 'papaparse' ;
export interface PlayStoreRow {
date : string ;
productId : string ;
appName : string ;
amountMerchantCurrency : number ;
merchantCurrency : string ;
}
/**
* Google Play の月次売上レポート (CSV) を GCS から取得します。
* バケット名は Google Play Console の「Financial Reports」ページに記載。
*/
export async function fetchPlayStoreSales (
serviceAccountKeyJson : string ,
bucketName : string , // 例: "pubsite_prod_rev_XXXXXXXXXX"
yearMonth : string , // YYYYMM
) : Promise < PlayStoreRow []> {
const auth = new GoogleAuth ({
credentials: JSON . parse (serviceAccountKeyJson),
scopes: [ 'https://www.googleapis.com/auth/devstorage.read_only' ],
});
const client = await auth. getClient ();
const token = ( await client. getAccessToken ()).token;
const objectPath = `sales/salesreport_${ yearMonth }.zip` ;
const url = `https://storage.googleapis.com/storage/v1/b/${ bucketName }/o/${ encodeURIComponent ( objectPath ) }?alt=media` ;
const res = await fetch (url, {
headers: { Authorization: `Bearer ${ token }` },
});
if (\ ! res.ok) {
throw new Error ( `Play Store GCS error: ${ res . status }` );
}
const zipBuffer = await res. arrayBuffer ();
const csvText = await extractCsvFromZip (zipBuffer);
const parsed = Papa. parse (csvText, { header: true , skipEmptyLines: true });
return (parsed.data as any []). map (( row ) => ({
date: row[ 'Transaction Date' ],
productId: row[ 'Product id' ],
appName: row[ 'Product Title' ],
amountMerchantCurrency: Number (row[ 'Amount (Merchant Currency)' ] || 0 ),
merchantCurrency: row[ 'Merchant Currency' ],
}));
}
async function extractCsvFromZip ( buffer : ArrayBuffer ) : Promise < string > {
const { unzipSync , strFromU8 } = await import ( 'fflate' );
const files = unzipSync ( new Uint8Array (buffer));
const csvFile = Object. keys (files). find (( name ) => name. endsWith ( '.csv' ));
if (\ ! csvFile) throw new Error ( 'No CSV found in Play Store ZIP' );
return strFromU8 (files[csvFile]);
}
なぜ GCS 経由を選ぶのか : Google Play の Reporting API には当月データを返すエンドポイントもありますが、月末締めで確定するまで数値が変動 します。「確定済み売上で月次 P/L を出す」用途には GCS の Financial Reports が最適です。日次の傾向把握には Reporting API、月次の会計には GCS、と使い分けるとよいでしょう。
Step 4: Cloudflare Workers での Cron バッチ実装
3つのフェッチャーができたら、Cron で毎朝動くバッチに組み上げます。
// src/worker.ts
import { fetchAppStoreSales, generateAppStoreJWT } from './fetchers/appstore' ;
import { fetchAdMobReport } from './fetchers/admob-report' ;
interface Env {
REVENUE_KV : KVNamespace ;
ASC_ISSUER_ID : string ;
ASC_KEY_ID : string ;
ASC_PRIVATE_KEY : string ;
ASC_VENDOR_NUMBER : string ;
ADMOB_SERVICE_KEY : string ;
ADMOB_ACCOUNT_ID : string ;
SLACK_WEBHOOK_URL : string ;
}
export default {
async scheduled ( controller : ScheduledController , env : Env , ctx : ExecutionContext ) {
const yesterday = new Date (Date. now () - 86400_000 ). toISOString (). slice ( 0 , 10 );
const [ appStore , adMob ] = await Promise . allSettled ([
( async () => {
const jwt = await generateAppStoreJWT ({
issuerId: env. ASC_ISSUER_ID ,
keyId: env. ASC_KEY_ID ,
privateKey: env. ASC_PRIVATE_KEY ,
});
return fetchAppStoreSales (jwt, env. ASC_VENDOR_NUMBER , yesterday);
})(),
fetchAdMobReport (env. ADMOB_SERVICE_KEY , env. ADMOB_ACCOUNT_ID , yesterday, yesterday),
]);
// KV に保存(prefix 設計: revenue:YYYY-MM-DD:platform)
if (appStore.status === 'fulfilled' ) {
await env. REVENUE_KV . put (
`revenue:${ yesterday }:appstore` ,
JSON . stringify (appStore.value),
{ expirationTtl: 60 * 60 * 24 * 365 * 3 }, // 3年保持
);
}
if (adMob.status === 'fulfilled' ) {
await env. REVENUE_KV . put (
`revenue:${ yesterday }:admob` ,
JSON . stringify (adMob.value),
);
}
await postDailySummaryToSlack (env. SLACK_WEBHOOK_URL , {
date: yesterday,
appStore: appStore.status === 'fulfilled' ? sumProceeds (appStore.value) : null ,
adMob: adMob.status === 'fulfilled' ? sumEarnings (adMob.value) : null ,
});
} ,
} ;
function sumProceeds ( rows : Array <{ developerProceeds : number }>) : number {
return rows. reduce (( acc , r ) => acc + r.developerProceeds, 0 );
}
function sumEarnings ( rows : Array <{ earnings : number }>) : number {
return rows. reduce (( acc , r ) => acc + r.earnings, 0 );
}
async function postDailySummaryToSlack (
webhook : string ,
summary : { date : string ; appStore : number | null ; adMob : number | null },
) : Promise < void > {
const lines = [
`*${ summary . date } の売上サマリ*` ,
summary.appStore \ !== null ? `App Store: $${ summary . appStore . toFixed ( 2 ) }` : 'App Store: 取得失敗' ,
summary.adMob \ !== null ? `AdMob: $${ summary . adMob . toFixed ( 2 ) }` : 'AdMob: 取得失敗' ,
];
await fetch (webhook, {
method: 'POST' ,
headers: { 'Content-Type' : 'application/json' },
body: JSON . stringify ({ text: lines. join ( ' \n ' ) }),
});
}
wrangler.toml に Cron を設定します。
[ triggers ]
crons = [ "0 22 * * *" ] # UTC 22:00 = JST 07:00 に毎日実行
なぜ Promise.allSettled なのか : 3プラットフォームのうち1つが 404 や 5xx を返しても、他の2つのデータは保存したいからです。Promise.all だと1つ失敗した時点で全部捨てることになり、Slack に「取得失敗」しか出ない日が出てしまいます。
Step 5: ダッシュボード UI(Next.js + Recharts)
Worker が KV にデータを貯めたら、Next.js で読み出して可視化します。API Route 経由で KV にアクセスする形が分かりやすいです。
// app/api/revenue/route.ts
import { getCloudflareContext } from '@opennextjs/cloudflare' ;
export async function GET ( request : Request ) {
const { searchParams } = new URL (request.url);
const days = Math. min ( Number (searchParams. get ( 'days' ) || '30' ), 90 );
const { env } = getCloudflareContext ();
const endDate = new Date ();
const startDate = new Date (Date. now () - days * 86400_000 );
const results : any [] = [];
for ( let d = new Date (startDate); d <= endDate; d. setDate (d. getDate () + 1 )) {
const ymd = d. toISOString (). slice ( 0 , 10 );
const [ appStore , adMob ] = await Promise . all ([
env. REVENUE_KV . get ( `revenue:${ ymd }:appstore` , 'json' ),
env. REVENUE_KV . get ( `revenue:${ ymd }:admob` , 'json' ),
]);
results. push ({
date: ymd,
appStoreTotal: sumOrZero (appStore, 'developerProceeds' ),
adMobTotal: sumOrZero (adMob, 'earnings' ),
});
}
return Response. json (results);
}
function sumOrZero ( data : any , key : string ) : number {
if (\ ! Array. isArray (data)) return 0 ;
return data. reduce (( acc , r ) => acc + (r[key] || 0 ), 0 );
}
// app/dashboard/page.tsx
'use client' ;
import { useEffect, useState } from 'react' ;
import { LineChart, Line, XAxis, YAxis, Tooltip, Legend, ResponsiveContainer } from 'recharts' ;
export default function Dashboard () {
const [ data , setData ] = useState < any []>([]);
useEffect (() => {
fetch ( '/api/revenue?days=30' ). then (( r ) => r. json ()). then (setData);
}, []);
return (
< div style = { { padding: 24 } } >
< h1 >モバイルアプリ収益ダッシュボード</ h1 >
< ResponsiveContainer width = "100%" height = { 400 } >
< LineChart data = { data } >
< XAxis dataKey = "date" />
< YAxis />
< Tooltip />
< Legend />
< Line type = "monotone" dataKey = "appStoreTotal" stroke = "#007aff" name = "App Store" />
< Line type = "monotone" dataKey = "adMobTotal" stroke = "#00c853" name = "AdMob" />
</ LineChart >
</ ResponsiveContainer >
</ div >
);
}
なぜ API Route を挟むのか : クライアント側から直接 KV を触ると、認証情報が露出します。API Route を挟むことで、認証・レート制限・キャッシュをサーバーサイドで一括管理できます。Cloudflare Pages + KV なら API Route はそのまま Workers として動くので、追加コストはありません。
よくある間違い・落とし穴
ここからは、実装中に私が実際に踏んだ地雷です。同じ失敗をしないでください。
落とし穴1: JWT の kid を間違えて無限 401 ループ
App Store Connect API の JWT ヘッダには kid(Key ID)を入れる必要があります。これは API Key 発行時に表示される 10 文字の ID で、P8 ファイル名(AuthKey_XXXXXXXXXX.p8)の XXXXXXXXXX 部分とも一致します。
間違えて Issuer ID や Team ID を入れると、エラーメッセージは「401 Unauthorized」としか出ません 。Claude Code に「JWT は正しいのに 401 が返る」と相談したら、すぐに「kid の値を確認してください」と指摘されて助かりました。似た文字列が複数あるので、コピペミスに注意です。
落とし穴2: Cloudflare Workers の node:zlib 利用可否
gunzipSync は Node.js 組み込みですが、Cloudflare Workers で使うには compatibility_flags = ["nodejs_compat"] を wrangler.toml に設定する必要があります。これを忘れると zlib is not defined で Worker 自体が起動しません。
main = "src/worker.ts"
compatibility_date = "2026-03-01"
compatibility_flags = [ "nodejs_compat" ]
落とし穴3: KV の Put レート制限(1キー/秒)
KV は同じキーへの書き込みが 1 秒に 1 回まで という制限があります。バッチを並列化しすぎて同じキーを同時に更新しようとすると、片方が silent fail(エラーを返さず書き込まれない)することがあります。
対策は、キー設計で衝突を避ける ことです。revenue:YYYY-MM-DD:platform のように日付とプラットフォームの両方を含めれば、並列でも異なるキーへの書き込みになるので安全です。
落とし穴4: AdMob の通貨混在
AdMob のレポートは、アプリの設定によっては 複数通貨が混在 します。USD のアプリと JPY のアプリを同じレポートに入れると、金額を単純合計すると意味のない値になります。
実装時は LOCALIZED_CURRENCY_CODE ディメンションを必ず含め、通貨ごとに集計してから、為替レート API でベース通貨(USD や JPY)に換算することをお勧めします。
落とし穴5: Sales Report の「当日分は翌日以降」
App Store Connect の日次売上は、UTC 00:00 で締めて、その後数時間以内に確定 します。バッチを JST 00:00 に回すと、UTC 15:00 時点で前日分はまだ集計中の可能性が高く、404 で返ってきます。
対策は、バッチを JST 07:00 以降に実行する ことです。経験上、JST 09:00 に回せばほぼ確実に前日データが取得できます。
運用してわかった設計判断
このシステムを半年ほど運用してみて、いくつか改善したポイントがあります。
再集計のための冪等性 : Apple は過去のデータを修正することがあります(返金処理など)。バッチは必ず「過去 N 日分を再取得する」モードを持つべきです。?days=7 のような引数で、過去1週間を再取得できるエンドポイントを用意しておくと、月次締めの整合性取りが楽になります。
失敗通知のしきい値 : Slack への失敗通知は、2回連続失敗したら初めて送る、くらいがちょうどよいです。API の一時的な不調で毎朝通知が来ると、本当に対応が必要な障害が埋もれます。
コストの視認性 : Cloudflare Workers の無料枠は広いですが、KV の Read が伸びやすいです。ダッシュボードが SNS で拡散したら上限に当たる可能性があるので、wrangler tail と Analytics で読み出し回数を見ておくと安心です。
応用: Claude Code に日次レポートを要約させる
最後の仕上げとして、Claude API で昨日の売上傾向を自然言語で要約し、Slack に投稿する応用パターンを紹介します。「昨日の AdMob が平均より20%低い。広告在庫か eCPM を確認しましょう」のような一文があるだけで、数字を見る負担が大幅に減ります。
import Anthropic from '@anthropic-ai/sdk' ;
async function generateInsight ( rows : any []) : Promise < string > {
const client = new Anthropic ({ apiKey: process.env. ANTHROPIC_API_KEY });
const response = await client.messages. create ({
model: 'claude-haiku-4-5-20251001' ,
max_tokens: 300 ,
messages: [{
role: 'user' ,
content: `以下は過去7日のアプリ売上データです。昨日の数値が通常と異なる点があれば1〜2文で指摘し、対応が必要なら具体的に書いてください。数値データの羅列ではなく、アクションに繋がる洞察を日本語で。 \n\n ${ JSON . stringify ( rows , null , 2 ) }` ,
}],
});
return response.content[ 0 ].type === 'text' ? response.content[ 0 ].text : '' ;
}
なぜ Haiku 4.5 を選ぶのか : この用途は「7日分の数値を見て1〜2文で要約する」だけなので、Opus や Sonnet を使うとコストがもったいないです。Haiku 4.5 は入力が短くても応答が自然で、1回 0.0001 ドル程度で済みます。毎日30回動かしても月 1 ドル以下です。
次に踏み出す一歩
この記事の内容を全部実装する必要はありません。まずは App Store Connect API の認証だけ通す ところから始めてください。JWT を作って、Sales Reports API に1回リクエストが通れば、残りの7割は同じパターンの繰り返しです。
認証で詰まったら、Claude Code に claude code と打って Plan Mode に入り、「App Store Connect の JWT 認証で 401 が出ているので原因を一緒に切り分けたい」と相談するのが早いです。私もそうやって進めました。
個人開発の売上を1つのダッシュボードで見られるようになると、次に伸ばすべきアプリが明確になります 。3つの管理画面を渡り歩いている時間が、戦略を考える時間に変わります。作って良かった、と半年経って断言できるシステムです。
補足: 過去データの一括バックフィル
Cron は今後の日付しかカバーしません。ダッシュボードを最初に表示させるには、過去数ヶ月分のデータも取得しておきたくなります。私が使っているパターンを共有します。
// src/backfill.ts
export async function backfillAppStore (
env : Env ,
startDate : string ,
endDate : string ,
) : Promise < void > {
const jwt = await generateAppStoreJWT ({
issuerId: env. ASC_ISSUER_ID ,
keyId: env. ASC_KEY_ID ,
privateKey: env. ASC_PRIVATE_KEY ,
});
const dates = enumerateDates (startDate, endDate);
for ( const date of dates) {
try {
const rows = await fetchAppStoreSales (jwt, env. ASC_VENDOR_NUMBER , date);
await env. REVENUE_KV . put ( `revenue:${ date }:appstore` , JSON . stringify (rows));
await sleep ( 1000 ); // Apple のレート制限を考慮して間隔を空ける
} catch (err) {
console. error ( `Backfill failed for ${ date }:` , err);
}
}
}
function enumerateDates ( start : string , end : string ) : string [] {
const dates : string [] = [];
const s = new Date (start);
const e = new Date (end);
for ( let d = new Date (s); d <= e; d. setDate (d. getDate () + 1 )) {
dates. push (d. toISOString (). slice ( 0 , 10 ));
}
return dates;
}
function sleep ( ms : number ) : Promise < void > {
return new Promise (( resolve ) => setTimeout (resolve, ms));
}
このバックフィル処理は、Cron ではなく 認証付きの手動エンドポイント として露出させてください。長時間動くので、スケジュールで意図せず走らせると面倒な事態になります。
観測性(小さく始める)
この規模のプロジェクトに Datadog や Sentry は要りません。Cloudflare の標準機能で十分やっていけます。
wrangler tail : デバッグ中のライブログ確認
Workers Analytics Engine : プラットフォーム別の処理時間・行数などを構造化メトリクスで記録
KV のヘルスエントリ : バッチ実行ごとに health:YYYY-MM-DDTHH:MM キーを書き込み、直近14件を読む /health ページを自作する
以下のような最小パターンで十分です。
await env. REVENUE_KV . put (
`health:${ new Date (). toISOString () }` ,
JSON . stringify ({
ok: appStore.status === 'fulfilled' && adMob.status === 'fulfilled' ,
appStore: appStore.status,
adMob: adMob.status,
duration_ms: Date. now () - startTime,
}),
{ expirationTtl: 60 * 60 * 24 * 30 },
);
シークレット管理の落とし穴
P8 秘密鍵を wrangler.toml にそのまま書くのは絶対にやめてください。wrangler secret put ASC_PRIVATE_KEY < key.p8 のようにファイル経由で投入する方法が安全です。
注意点として、Cloudflare は秘密鍵の末尾改行を trim することがあります。importPKCS8 で「Invalid key format」が出たら、コード側で改行を補正しておくと安定します。
const normalizedKey = env. ASC_PRIVATE_KEY . endsWith ( ' \n ' )
? env. ASC_PRIVATE_KEY
: env. ASC_PRIVATE_KEY + ' \n ' ;
デプロイ当日に原因不明の 401 が出る、という週末を避けるための1行です。