直近の2ヶ月ほど、運用中の壁紙アプリの広告メディエーションを AdMob 単独から AdMob と AppLovin MAX の並走体制に切り替え、その判断材料を Antigravity のサブエージェント群に日次集計させていました。私が個人開発を始めたのが 2014 年、6 アプリ並行で運用している現時点で AdMob の累計収益は事業の柱になっており、移行判断は軽い気持ちでは下せません。一方で AppLovin MAX のオークション挙動は数字で見るほどに優位な国・ユニットも見えてきます。そこで「結論を急がず、毎日同じものさしで両者を観測する」運用を Antigravity に任せることにしました。
このメモは、その90日間で実装と運用を改めた記録です。広告 SDK の入れ替え方法そのものではなく、レポート API の取り扱い、サブエージェント間の責務分割、異常検知の閾値、判断を人間に渡す境界線、といった「比較を続けるための足場」を書きます。
並走を選んだ理由と、観測したい3つの数字
AppLovin MAX への完全移行を即断しなかったのは、過去に別のメディエーション SDK で「導入後しばらく好調 → 季節要因で落ち込み → 切り戻しの判断材料がなくて困った」という経験があるためです。判断の足場として、最低3ヶ月は同条件で両ネットワークの数字を見たいと考えました。
観測対象を絞り込む過程で、最終的に次の3メトリクスに絞りました。
eCPM (千回表示あたり収益)— 単価そのもの
Fill Rate (広告リクエストに対する充足率)— インベントリの厚み
ARPDAU (DAU あたり広告収益)— ユーザー側から見た収益効率
eCPM だけを追うと、Fill Rate が下がっているのに単価が上がって「総額は減っているのに勝っているように見える」現象に騙されます。AdMob 一本で運用していたときに何度か遭遇したのですが、当時は手動で関連レポートを開いて確認していました。3 指標を連動で見ることがメディエーション比較の最低条件だと割り切り、エージェントの出力もこの 3 列構成で固定しています。
サブエージェントに分割した4つの責務
Antigravity のサブエージェント機能は「ひとつのタスクを長く回す」より「責務単位で小さく切って、結果を文字列でつなぐ」方が壊れにくいことが90日間で見えてきました。今回は4つに分割しています。
collector-admob : AdMob Reporting API からの日次取得と JSON 正規化
collector-applovin : AppLovin MAX Reporting API からの取得と正規化
normalizer : 国・通貨・広告ユニットの命名揺れを統一して比較可能な単一テーブルに整形
comparator : 3 メトリクスの差分計算と、しきい値超過時の通知文生成
それぞれを独立した .agent ファイルに分け、Antigravity の Orchestrator から順に呼び出します。Comparator だけが他3つの出力に依存します。AGENTS.md の dependencies 節で明示しており、これにより collector-applovin が失敗しても collector-admob の結果はキャッシュされ、翌日の比較は片肺で実行できます。私自身、最初は Orchestrator 1 つで全部やろうとして「途中でレポート API がタイムアウト → 全部やり直し」を 2 度踏み、責務分割に逃げた経緯があります。
AdMob Reporting API を Antigravity から叩く実装
AdMob 側は googleads-nodejs-client を採用しました。サービスアカウントの権限スコープと、レポート粒度のパラメータが落とし穴になりやすいので、collector-admob のコアだけ抜粋します。
// agents/collector-admob/index.ts
import { GoogleAuth } from "google-auth-library" ;
const SCOPES = [ "https://www.googleapis.com/auth/admob.readonly" ];
interface AdMobRow {
date : string ; // YYYY-MM-DD
country : string ; // ISO-3166 alpha-2
adUnit : string ;
impressions : number ;
estimatedEarnings : number ; // micro-units, 6桁デノミ
matchRate : number ; // 0..1
}
export async function fetchAdMobDaily (
publisherId : string ,
dateRange : { start : string ; end : string }
) : Promise < AdMobRow []> {
const auth = new GoogleAuth ({ scopes: SCOPES });
const client = await auth. getClient ();
const url = `https://admob.googleapis.com/v1/accounts/${ publisherId }/networkReport:generate` ;
const body = {
reportSpec: {
dateRange: { startDate: parseDate (dateRange.start), endDate: parseDate (dateRange.end) },
dimensions: [ "DATE" , "COUNTRY" , "AD_UNIT" ],
metrics: [ "IMPRESSIONS" , "ESTIMATED_EARNINGS" , "MATCH_RATE" ],
localizationSettings: { currencyCode: "USD" , languageCode: "en-US" },
},
};
const res = await client. request < unknown >({ url, method: "POST" , data: body });
return normalizeAdMobResponse (res.data);
}
function parseDate ( iso : string ) {
const [ year , month , day ] = iso. split ( "-" ). map (Number);
return { year, month, day };
}
ここで重要なのは currencyCode: "USD" で固定している点です。AdMob のレポート API は呼び出しごとに通貨を切り替えられますが、AppLovin MAX 側もドル建てで揃えるために USD に固定します。私の運用では国内ユーザーの割合が4割ほどあるので円建てで見たくなるのですが、為替変動が毎日比較のノイズになるため、判断はドルで、表示用の円換算は最後のレイヤーに分離しました。
もうひとつ、MATCH_RATE を Fill Rate の代理指標にしている点も実務的なポイントです。AdMob のドキュメントでは Fill Rate と Match Rate は厳密には異なりますが、メディエーション後の「最終的に表示された割合」を比較したい用途では Match Rate のほうが現場感覚に近い数字になります。
AppLovin MAX 側のレポート取得と通貨揃え
AppLovin MAX の Reporting API は CSV ベースで、AdMob と粒度の癖が違います。collector-applovin では一度 CSV を取り出した後で JSON 化し、AdMob と同じスキーマに寄せる工程を入れています。
// agents/collector-applovin/index.ts
import { parse } from "csv-parse/sync" ;
interface MaxRow {
date : string ;
country : string ;
adUnit : string ;
impressions : number ;
estimatedEarnings : number ; // USD float
fillRate : number ; // 0..1
}
export async function fetchMaxDaily (
apiKey : string ,
dateRange : { start : string ; end : string }
) : Promise < MaxRow []> {
const url = new URL ( "https://r.applovin.com/maxReport" );
url.searchParams. set ( "api_key" , apiKey);
url.searchParams. set ( "start" , dateRange.start);
url.searchParams. set ( "end" , dateRange.end);
url.searchParams. set ( "columns" , "day,country,ad_unit_name,impressions,estimated_revenue,fill_rate" );
url.searchParams. set ( "format" , "csv" );
const res = await fetch (url. toString ());
if ( ! res.ok) throw new Error ( `MAX report failed: ${ res . status }` );
const csv = await res. text ();
const rows = parse (csv, { columns: true , skip_empty_lines: true });
return rows. map (( r : Record < string , string >) => ({
date: r.day,
country: r.country,
adUnit: r.ad_unit_name,
impressions: Number (r.impressions),
estimatedEarnings: Number (r.estimated_revenue),
fillRate: Number (r.fill_rate) / 100 ,
}));
}
レポート列の fill_rate がパーセント表記である一方、内部スキーマでは 0〜1 のレシオに揃えています。これを忘れると後段の comparator が「Fill Rate 75% を 0.75 倍と勘違いして AdMob 圧勝に見せかける」というバグを生みます。実際に最初のリリースで踏みました。シンプルな単位揃えこそ、ネットワーク横断の比較で最もミスが多い部分です。
命名揺れを吸収する normalizer
AdMob と AppLovin MAX で同じ広告ユニットを指していても、ダッシュボードに登録した名前が少しずつ違うことがあります。たとえば AdMob 側で Wallpaper_App_iOS_Native_Home と命名していて、AppLovin MAX 側で home_native_ios のように省略していると、横並びで比較できません。
normalizer エージェントには、ユニット命名規則の対応表をひとつ持たせて、両方を共通のキーに射影しています。
// agents/normalizer/aliases.ts
export const AD_UNIT_ALIASES : Record < string , string > = {
"Wallpaper_App_iOS_Native_Home" : "ios_home_native" ,
"home_native_ios" : "ios_home_native" ,
"Wallpaper_App_iOS_Interstitial_Detail" : "ios_detail_interstitial" ,
"detail_interstitial_ios" : "ios_detail_interstitial" ,
};
export function canonicalAdUnit ( raw : string ) : string {
return AD_UNIT_ALIASES [raw] ?? raw. toLowerCase (). replace ( / \s + / g , "_" );
}
90日間運用して気付いたのは、ここを「自動推測」させるとほぼ確実に事故るということです。Antigravity に類似ユニットの自動マッピングをやらせると、似ているけど別物のユニットを同一視してしまい、収益判定が大きくぶれます。手で書ける程度のサイズなので、固定マップに留めることをお勧めします。私は6アプリ並行で約50ユニットを管理していますが、それでもマップは300行程度に収まっています。
3メトリクスを連動で見るしきい値設計
normalizer の出力を受けて、comparator が日次サマリを生成します。判断の足場として採用しているしきい値は次の通りです。
eCPM 差 : AppLovin MAX が AdMob に対して +10% 以上で「優位候補」
Fill Rate 差 : AppLovin MAX が AdMob に対して -5% 以下に落ちていたら「優位候補から除外」
ARPDAU 差 : 7日移動平均で +5% 以上の差が継続したら「移行検討の通知」
eCPM 単体ではなく、Fill Rate のガードレールを噛ませているのが要点です。eCPM が高くても、表示されなければ ARPDAU は伸びません。Fill Rate を見落とすと「単価が上がっただけで実収益が落ちる」結果を「成功」と誤読してしまいます。
comparator のコアロジックは次のような構造です。
// agents/comparator/index.ts
interface MergedRow {
country : string ;
adUnit : string ;
admob : { ecpm : number ; fillRate : number ; arpdau : number };
max : { ecpm : number ; fillRate : number ; arpdau : number };
}
export function classify ( row : MergedRow ) : "max-superior" | "admob-superior" | "tie" {
const ecpmDelta = (row.max.ecpm - row.admob.ecpm) / row.admob.ecpm;
const fillDelta = row.max.fillRate - row.admob.fillRate;
const arpdauDelta = (row.max.arpdau - row.admob.arpdau) / row.admob.arpdau;
// Fill Rate ガードレール
if (fillDelta < - 0.05 ) return "admob-superior" ;
if (ecpmDelta >= 0.10 && arpdauDelta >= 0.05 ) return "max-superior" ;
if (ecpmDelta <= - 0.10 && arpdauDelta <= - 0.05 ) return "admob-superior" ;
return "tie" ;
}
このロジック自体は単純ですが、実運用での価値は「判断のばらつき」を消すところにあります。私が手動でメディエーション比較していた頃は、その日の気分や、たまたま目にしたユニットの数字で結論が揺れていました。エージェントに同じルールで毎日判定させると、季節要因や週末効果を含めた俯瞰が積み上がっていきます。
異常検知と、人間に上げる境界線
毎日のサマリは Slack の専用チャンネルに飛ばしていますが、すべての差分を通知すると埋もれます。Antigravity の comparator では、次のいずれかが起きた日だけ「人間が見るべき日」として優先通知しています。
同一の (country, adUnit) ペアで7日連続で同じ側が優位
ARPDAU の前週比が ±15% を超える急変
いずれかのコレクターが2日連続で失敗
最初は「毎日のサマリも全部 Slack に飛ばす」運用にしていたのですが、私が見落とすようになって意味がなくなりました。1日のメッセージを「上位3ユニットの優劣」と「警告候補3つまで」に絞ったところ、通知への反応速度が3日 → 半日に縮まりました。AI エージェントに任せる時に、出力量を絞る判断は手動運用以上に効くと感じています。
人間に上げる判断の境界線として、もうひとつ採用しているのが「決定権の保留」です。Antigravity は移行候補を提案するだけで、AdMob 側でユニットを停止する操作は自動化していません。広告 SDK の停止は段階公開とリリースサイクルの影響が大きく、AI に決断させる範囲を超えると判断しました。これは個人開発で6アプリ並行している立場での慎重さでもあり、規模が大きくなれば変わるかもしれません。
90日間運用して見えた数字と次の判断
90日間の集計で見えた主な結論を、政樹個人の運用ログから抜粋しておきます。固有の広告ネットワーク・国・ユニット名は伏せますが、傾向としては以下のとおりでした。
北米のインタースティシャル広告ユニットでは、AppLovin MAX が AdMob 比で eCPM +18% / Fill Rate -2% / ARPDAU +12% と、ガードレールを越えて優位
日本のネイティブ広告ユニットでは、AppLovin MAX が AdMob 比で eCPM +4% / Fill Rate -8% / ARPDAU -3% と、Fill Rate ガードレールで失格
月収ベース(6アプリ合算)では、両者並走の現状で月平均 4〜6% の上振れが見られた。これは移行というより「両者並走のメディエーション化そのものの効果」と解釈
90日の段階で完全移行は見送り、北米インタースティシャルから段階的に AppLovin MAX 比重を上げる方針に切り替えました。Antigravity サブエージェント群はそのまま動かし続け、四半期ごとに同じレポートを出させる予定です。
個人開発で2014年から AdMob を主柱に置いてきた立場としては、判断を急がず、同じものさしで観測を継続できる仕組みのほうが結果的に長持ちすると感じています。AI エージェントの本当の価値は「結論を出すこと」ではなく、「結論を出すための足場を毎日同じ品質で積むこと」だと、この90日間で改めて思いました。
次に同じテーマで書くなら、Pre-Launch Report 自動消化や Crashlytics の クラッシュ分類エージェントとの統合について整理する予定です。実装の参考になれば幸いです。