祝!AdSense通過!広告収益分配ツールを作ってみた
こんにちは。Kalsarikannintのフロントエンド担当・daiです。
先日、Google AdSenseの審査に通過しました。やっと広告を貼り始めたので、今回はその裏側と、2人で運営しているブログだからこその「利益分配ツール」についてお話しします。
広告の掲載位置

PCでブログを見ていただいている場合、記事右側の余白部分に広告を表示しています。
掲載位置についてはAfu.とも話し合って、収益よりも、なるべく邪魔にならず、コンテンツに集中できるような場所を選んで設定したつもりです。
また、スマートフォンでご覧の場合は、記事の末尾に1つだけ掲載しています。
もちろん、掲載数が多かったり、記事内に差し込むように配置するほうが広告収益が高くなりやすいですが、そういうサイトは自分たちも好きではないので、このような場所に落ち着きました。
収益の分配のために…
共同運営だからこそ、「どれだけのPVがあるか」に応じて利益を公平に配る仕組みが必要です。そこで今回は以下の手順で自動化してみることにしました。
- Google Analyticsから、月次でページごとのPV数を取得
- AdSenseレポートから、月次で売上金額を取得
- 自分が執筆したページのPV数をもとに、売上金額を配分
そしてすべてをスプレッドシート上で確認できるようにしています。


上記すべてGoogleサービスなので、GASを使うことで簡単に実装することができました。
/***** 設定 ********************************************/
const CONFIG = {
GA4_PROPERTY_ID: '', // ← GA4 Property ID を入れる
CURRENCY_FORMAT: '¥#,##0',
PAGE_LIST_SHEET_NAME: 'ページ一覧',
DETAIL_START_ROW: 9,
ADSENSE_ACCOUNT_NAME: 'accounts/pub-', // ← GoogleAdsense ID を入れる
OVERWRITE_PAGE_LIST_TITLES: false
};
/******************************************************/
function onOpen() {
SpreadsheetApp.getUi()
.createMenu('月次集計')
.addItem('先月分の明細を作成', 'runMonthly')
.addItem('毎月10日に自動実行を設定', 'createMonthlyTrigger')
.addToUi();
}
/** メイン:先月分のシートを作成 */
function runMonthly() {
const ss = SpreadsheetApp.getActive();
const {start, end, ym} = getPrevMonthRange_();
// 1) 前月 AdSense 売上(合計)
const earnings = fetchAdsenseEarnings_(start, end, 'kalsari.net'); // number
// 2) 前月 GA4 ページ別PV
const pvRows = fetchGa4Pageviews_(start, end); // [{url,title,views}]
const aggregated = aggregateByCanonicalUrl_(pvRows); // [[url,title,views],...]
// 3) ページ一覧シートを更新(URL追加・重複なし)
upsertPageList_(aggregated);
// 4) 明細シート生成
buildMonthlySheet_(ss, ym, earnings, aggregated);
}
/** 毎月10日 9:00 に実行するトリガーを作成(任意) */
function createMonthlyTrigger() {
const found = ScriptApp.getProjectTriggers().some(t => t.getHandlerFunction() === 'runMonthly');
if (!found) {
ScriptApp.newTrigger('runMonthly').timeBased().onMonthDay(10).atHour(9).create();
}
SpreadsheetApp.getUi().alert('毎月10日9:00に実行するトリガーを設定しました。');
}
/* ========= AdSense(前月売上合計) ================== */
/**
* AdSense 前月の推定収益合計を返す(円換算はAdSense側の通貨)
* Advanced Service: AdSense (v1.4) を利用
*/
function fetchAdsenseEarnings_(start, end, domain) {
const account = getAdsenseAccountName_();
const s = toParts_(start);
const e = toParts_(end);
// まず v2想定のディメンション名(OWNED_SITE_DOMAIN_NAME)で試す
const tryOnce = (dimensionName) => {
const params = {
dateRange: 'CUSTOM',
'startDate.year': s.y,
'startDate.month': s.m,
'startDate.day': s.d,
'endDate.year': e.y,
'endDate.month': e.m,
'endDate.day': e.d,
metrics: ['ESTIMATED_EARNINGS'],
dimensions: [dimensionName],
// フィルタ構文は「DIMENSION==value」
filters: [`${dimensionName}==${domain}`]
};
const res = AdSense.Accounts.Reports.generate(account, params);
// 合計は footer.totals が最も確実
if (res && res.footer && res.footer.totals && res.footer.totals.length) {
return parseFloat(res.footer.totals[0].value || 0) || 0;
}
// 行が返るタイプの場合
if (res && res.rows && res.rows.length && res.rows[0].cells && res.rows[0].cells.length) {
return parseFloat(res.rows[0].cells[0].value || 0) || 0;
}
return 0;
};
try {
let val = tryOnce('OWNED_SITE_DOMAIN_NAME'); // v2
if (val !== 0) return val;
// 念のため旧称でも再トライ(v1.4)
return tryOnce('DOMAIN_NAME');
} catch (err) {
// 一部環境では filters が効かない場合があるので、そのときは全件→手元で抽出
const fallbackParams = {
dateRange: 'CUSTOM',
'startDate.year': s.y,
'startDate.month': s.m,
'startDate.day': s.d,
'endDate.year': e.y,
'endDate.month': e.m,
'endDate.day': e.d,
metrics: ['ESTIMATED_EARNINGS'],
dimensions: ['OWNED_SITE_DOMAIN_NAME'] // だめなら 'DOMAIN_NAME' に変えて再実行してもOK
};
const res = AdSense.Accounts.Reports.generate(account, fallbackParams);
const rows = (res && res.rows) ? res.rows : [];
for (const r of rows) {
const cells = r.cells || [];
const dimVal = cells[0] && cells[0].value;
const metricVal = cells[1] && cells[1].value;
if (dimVal === domain) return parseFloat(metricVal || 0) || 0;
}
throw new Error('指定ドメインの売上が見つかりませんでした。');
}
}
function toParts_(ymd) {
const [y, m, d] = ymd.split('-').map(n => parseInt(n, 10));
return { y, m, d };
}
function getAdsenseAccountName_() {
if (CONFIG.ADSENSE_ACCOUNT_NAME) return CONFIG.ADSENSE_ACCOUNT_NAME;
const resp = AdSense.Accounts.list({pageSize: 50}); // v2
if (!resp.accounts || resp.accounts.length === 0) {
throw new Error('AdSenseアカウントが見つかりません。権限とAPI有効化を確認してください。');
}
// 1つだけ使う想定。複数ある場合は CONFIG に明示してください。
return resp.accounts[0].name; // 例: "accounts/pub-1234567890123456"
}
function toDateObj_(ymd) {
const [y,m,d] = ymd.split('-').map(n => parseInt(n,10));
return {year:y, month:m, day:d};
}
/* ========= GA4(前月ページ別PV) ==================== */
/**
* GA4 Data API: pageLocation(完全URL) / pageTitle / screenPageViews
* Advanced Service: AnalyticsData を利用
*/
function fetchGa4Pageviews_(start, end) {
const propertyName = `properties/${CONFIG.GA4_PROPERTY_ID}`;
const req = {
dateRanges: [{ startDate: start, endDate: end }],
dimensions: [{ name: 'pageLocation' }, { name: 'pageTitle' }],
metrics: [{ name: 'screenPageViews' }], // GA4のPV
limit: 100000
};
try {
const res = AnalyticsData.Properties.runReport(req, propertyName);
const rows = res.rows || [];
return rows.map(r => ({
url: r.dimensionValues[0].value || '',
title: r.dimensionValues[1].value || '',
views: parseInt(r.metricValues[0].value || '0', 10)
})).filter(x => x.url);
} catch (e) {
throw new Error('GA4のデータ取得に失敗しました。Advanced Service「AnalyticsData」を有効化し、Property ID を設定してください。\n' + e);
}
}
/* ========= URL正規化 & 集計 ========================= */
function aggregateByCanonicalUrl_(rows) {
const map = new Map(); // key=canonicalURL, val={title, views}
rows.forEach(({url, title, views}) => {
const key = toCanonicalUrl_(url);
const prev = map.get(key) || { title: title || '', views: 0 };
// タイトルはPVが多いものを優先
const newViews = prev.views + (Number(views) || 0);
const newTitle = (Number(views) > prev.views && title) ? title : prev.title || title || '';
map.set(key, { title: newTitle, views: newViews });
});
const out = Array.from(map.entries()).map(([u, o]) => [u, o.title, o.views]);
// PV降順
out.sort((a, b) => b[2] - a[2]);
return out;
}
/** 例: http://www.Example.com/path/?utm_source=x → https://example.com/path */
function toCanonicalUrl_(raw) {
try {
const u = new URL(raw);
let host = u.hostname.toLowerCase().replace(/^www\./, '');
let path = u.pathname.replace(/\/+$/, ''); // 末尾スラッシュ除去(ルート以外)
if (path === '') path = '/';
return `https://${host}${path}`;
} catch (e) {
// 万一 pageLocation が壊れている場合は文字列操作で頑張る
return String(raw).replace(/^https?:\/\//, 'https://')
.replace(/\/\/www\./, '//')
.replace(/[?#].*$/, '')
.replace(/\/+$/, '');
}
}
/* ========= ページ一覧シートの更新 ==================== */
function upsertPageList_(aggregated) {
const ss = SpreadsheetApp.getActive();
const name = CONFIG.PAGE_LIST_SHEET_NAME;
let sh = ss.getSheetByName(name);
if (!sh) {
sh = ss.insertSheet(name);
}
// 見出し
sh.getRange(1,1).setValue('★ページ一覧');
sh.getRange(3,1,1,3).setValues([['ページ','タイトル','執筆者']]).setFontWeight('bold');
// 既存データの取得(A:URL, B:タイトル, C:執筆者)—4行目から
const last = sh.getLastRow();
const existing = new Map(); // url -> {row, title, author}
if (last >= 4) {
const vals = sh.getRange(4,1,last-3,3).getValues();
vals.forEach((r,i) => {
const url = r[0];
if (!url) return;
const cu = toCanonicalUrl_(url);
const row = 4 + i;
existing.set(cu, { row, title: r[1] || '', author: r[2] || '' });
// URLの表記ゆれは正規化して書き戻し
if (url !== cu) sh.getRange(row, 1).setValue(cu);
});
}
const adds = []; // 追記用 [url,title,author]
const titleUpdates = []; // 既存のタイトル空欄に補完 or 上書き
aggregated.forEach(([url, title]) => {
const cu = toCanonicalUrl_(url);
const e = existing.get(cu);
if (!e) {
adds.push([cu, title || '', '']); // 新規は執筆者空欄で追加
existing.set(cu, { row: null, title: title||'', author: '' });
} else {
const shouldOverwrite =
CONFIG.OVERWRITE_PAGE_LIST_TITLES
? (title && title !== e.title)
: (!e.title && title);
if (shouldOverwrite) titleUpdates.push({ row: e.row, title });
}
});
if (adds.length) {
const start = sh.getLastRow() + 1;
sh.getRange(start, 1, adds.length, 3).setValues(adds);
}
titleUpdates.forEach(u => sh.getRange(u.row, 2).setValue(u.title));
sh.setFrozenRows(3);
sh.autoResizeColumns(1,3);
}
/* ========= 明細シートの作成 ========================= */
function buildMonthlySheet_(ss, ym, earnings, details) {
// 既存シートがあれば作り直し
const ex = ss.getSheetByName(ym);
if (ex) ss.deleteSheet(ex);
const sh = ss.insertSheet(ym);
const startRow = CONFIG.DETAIL_START_ROW || 9;
// タイトル
sh.getRange('A1').setValue('★支払い明細書').setFontWeight('bold');
// 今月の売上(前月合計)
sh.getRange('E2').setValue('今月の売上');
sh.getRange('E3').setValue(earnings).setNumberFormat(CONFIG.CURRENCY_FORMAT);
// ── 支払い明細(執筆者集計)を右側に配置:H~K 列 ──
sh.getRange('H2:K2')
.setValues([['執筆者','PV数合計','PV割合','支払い額']])
.setFontWeight('bold')
.setBackground('#f5f5f5');
// 執筆者ごとの PV 合計をQUERYでまとめて H3:I に出力(スピルは必要分だけ)
sh.getRange(3, 8).setFormula(
`=QUERY({$D$${startRow}:$D,$C$${startRow}:$C},` +
`"select Col1, sum(Col2) ` +
` where Col1 is not null ` +
` group by Col1 ` +
` order by sum(Col2) desc ` +
` label sum(Col2) ''", 0)`
);
// 割合と支払い額(J3/K3)— 右側なので他データと衝突しません
sh.getRange(3, 10)
.setFormula(`=ARRAYFORMULA(IF(H3:H="",,I3:I/SUM(I3:I)))`)
.setNumberFormat('0.0%');
sh.getRange(3, 11)
.setFormula(`=ARRAYFORMULA(IF(J3:J="",,J3:J*$E$3))`)
.setNumberFormat(CONFIG.CURRENCY_FORMAT);
// ── 月間PV明細(A~D 列) ──
sh.getRange(7,1).setValue('★月間PV明細').setFontWeight('bold');
sh.getRange(8,1,1,4).setValues([['ページ','タイトル','PV数','執筆者']]).setFontWeight('bold').setBackground('#e8f0fe');
if (details.length) {
// A:URL, B:タイトル, C:PV数
sh.getRange(startRow,1,details.length,3).setValues(details);
}
// D列 執筆者はページ一覧(A:URL,B:タイトル,C:執筆者)からVLOOKUP
sh.getRange(startRow,4).setFormula(
`=ARRAYFORMULA(IF(A${startRow}:A="","",IFERROR(VLOOKUP(A${startRow}:A,'${CONFIG.PAGE_LIST_SHEET_NAME}'!A:C,3,false),"")))`
);
// 体裁
sh.setFrozenRows(8);
sh.getRange(startRow,3,Math.max(details.length,1),1).setNumberFormat('#,##0');
sh.autoResizeColumns(1, 11);
}
/* ========= 日付ユーティリティ ======================= */
function getPrevMonthRange_() {
const today = new Date();
const firstThis = new Date(today.getFullYear(), today.getMonth(), 1);
const lastPrev = new Date(firstThis - 1); // 前月末
const firstPrev = new Date(lastPrev.getFullYear(), lastPrev.getMonth(), 1);
const pad = n => ('0' + n).slice(-2);
const start = `${firstPrev.getFullYear()}-${pad(firstPrev.getMonth()+1)}-01`;
const end = `${lastPrev.getFullYear()}-${pad(lastPrev.getMonth()+1)}-${pad(lastPrev.getDate())}`;
const ym = `${firstPrev.getFullYear()}-${pad(firstPrev.getMonth()+1)}`;
return { start, end, ym };
}
仲良くモチベ高く続けるために
広告収益はモチベーションになる一方、利益配分が不透明だと争いの種になりやすいです。
そんなことで揉めたくない、かといって集計に手間をかけたくないので、自動でデータを集めてくれるツールを導入しておくことで仲良くモチベも高く続けられるようにしています。
現状はスプレッドシートに書き込むだけですが、次のステップとして Slack 通知を追加したいと考えています。これなら、支払額が決まるたびに Slack チャンネルで共有できて、お互いがちゃんと把握できます。
もしあなたも共同運営ブログやチームで記事を書いているなら、ぜひ参考にしてみてください。
実装はちょっと手間かもしれませんが、長期的には「誰がどれだけ貢献したか」が明確になるので、関係性を円滑に保つ助けになります。競争心を煽って記事が良質になっていく…かも…?