Kalsarikannint

お問い合わせ

サイト内検索

祝!AdSense通過!広告収益分配ツールを作ってみた

こんにちは。Kalsarikannintのフロントエンド担当・daiです。

先日、Google AdSenseの審査に通過しました。やっと広告を貼り始めたので、今回はその裏側と、2人で運営しているブログだからこその「利益分配ツール」についてお話しします。

広告の掲載位置

PCでブログを見ていただいている場合、記事右側の余白部分に広告を表示しています。

掲載位置についてはAfu.とも話し合って、収益よりも、なるべく邪魔にならず、コンテンツに集中できるような場所を選んで設定したつもりです。

また、スマートフォンでご覧の場合は、記事の末尾に1つだけ掲載しています。

もちろん、掲載数が多かったり、記事内に差し込むように配置するほうが広告収益が高くなりやすいですが、そういうサイトは自分たちも好きではないので、このような場所に落ち着きました。

収益の分配のために…

共同運営だからこそ、「どれだけのPVがあるか」に応じて利益を公平に配る仕組みが必要です。そこで今回は以下の手順で自動化してみることにしました。

  1. Google Analyticsから、月次でページごとのPV数を取得
  2. AdSenseレポートから、月次で売上金額を取得
  3. 自分が執筆したページのPV数をもとに、売上金額を配分

そしてすべてをスプレッドシート上で確認できるようにしています。

▲ 月次の集計ページ。毎月自動で追加されます。(7月は広告貼ってなかったから売上0円…)
▲ 各ページを誰が書いたかをメモするページ。ページとタイトルは自動追加。執筆者は今のところ手動…

上記すべて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 チャンネルで共有できて、お互いがちゃんと把握できます。


もしあなたも共同運営ブログやチームで記事を書いているなら、ぜひ参考にしてみてください。

実装はちょっと手間かもしれませんが、長期的には「誰がどれだけ貢献したか」が明確になるので、関係性を円滑に保つ助けになります。競争心を煽って記事が良質になっていく…かも…?

Author dai

地方に住みながら、フルリモート情シスおじさんしてます。
フロントエンド開発とkintoneアプリのカスタマイズやプラグイン開発が得意分野です。
好きなビールは一番搾り。

MindMinerで“頭の中”をスッキリ整理してみた

こんにちは。Kalsarikannintのフロントエンド担当・daiです。 最近、Chat GPTなどのAIツールを使って思考を整理する人が増えてきました。でも正直、出てきた言葉が「自分の言葉じゃない」ように感じて、どこか違和感を覚えたことはありませんか? そんな中、MindMinerというツールを使ってみたら、すごくちょうどよかったのでご紹介します! MindMinerってどんなツール? MindMinerは、自分の言葉で、思考を整理できるマインドマップツールです。AIがそっと手助けしてくれるので、言葉を探す手がかりにはなるけど、主役はあくまで“自分の思考”。 アイデアの整理はもちろん、軽いメモ代わりに常にタブに開いておくのもアリ。気負わずに使えるのが、なによりの魅力です。 使い方はシンプルそのもの 登録不要・完全無料 アクセスするだけですぐ使える 入力した内容は自動でブラウザに保存 ※ただし、キャッシュを消すとデータも消えるので注意! 開いたら、あとはひたすら言葉を入力していくだけ。 その言葉に関連する
tool

WordPressのテーマ自作は怖くない【第二回:記事一覧ページを作ろう】

こんにちは。Kalsarikannintのフロントエンド担当・daiです。 前回の更新から時間が空いてしまいましたが、引き続き、WordPressで最小構成でテンプレートを作成する手順を紹介していきます。 第二回の今回は、カテゴリごとやタグごとの記事、検索結果を一覧で表示するページを作成してみようと思います。このブログでいうとこのページに該当します。 前回のおさらい 前回は、「トップページ」と「記事詳細ページ」を表示させるために必要なファイルを作成しました。 wp-content/ └ themes/ └ kalsarikannint-theme/ ├ images/ ├ style/ │ ├ archive.css # new │ ├ common.css │ ├ front-page.css │ └ single-post.css ├ archi
wordpress

WordPressのテーマ自作は怖くない 【第一回:最小構成でテーマを作ろう】

こんにちは。Kalsarikannintのフロントエンド担当・daiです。 WordPress、使ってますか?未だに無料CMSの中では右に出る者はいないレベルで支持されていますが、かくいうこのサイトも、WordPressで作られています。 そして、デザインの要となるテーマについては、ボイラープレートすら使わずに、イチから自作しています。 PHPが苦手なこともあり、ほんの数ヶ月前まではなるべくWordPressを避けてきた僕ですが、やってみると意外と怖くなかったよ、ってことをシリーズ(全4回の予定)でお伝えできればと思います。 いままで静的なサイトは作ったことがあるけど、「複雑そう」とか「既存のテーマをイジるのが怖い」といった理由でWordPressを避けていたコーダーの方の背中を押すきっかけになれば幸いです。 まずは最小構成で作ってみよう このサイトのもともとのテーマが「シンプル」「15年前のインターネット」だったこともあり、最小構成で成り立っています。WordPress初心者の方も、まずは「トップページ」と「記事ページ」だけのシンプルなサイトで作
wordpress