0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

検索広告の仕組みを自作実装して学んでみる

Posted at

はじめに

以前、GA4のような計測技術を自前実装して学習しましたが、今回は検索広告の仕組みを実装してみました。

Google広告のgtag.jsと同じような設計思想で、以下を再現してみています。

  • 広告クリックIDの管理とアトリビューション
  • オーディエンスセグメントの自動生成
  • キーワード+オーディエンスのターゲティング

この記事では、どのようなコードでデジタル広告の技術を再現できるのかを解説します。今回もClaude Codeを使いながら実装や記事の執筆を試してみました。

使用した技術

前回記事同様簡易的な実装としていて、Google Apps Script(以降GAS)とGoogleスプレッドシートをバックエンド・データベースとして使用しています。
フロントエンド側、いわゆる広告タグ(Googleタグ)にあたるものはJavaScript1ファイルで実装し、サンプルで用意したHTMLファイルに読み込ませて使用しています。
検索画面も簡易的にHTML/JavaScriptで実装しました。

実装内容

先に今回作成したコードを以下にまとめますので、興味のある方は使ってみてください。

バックエンド(GAS)

code.gs

スプレッドシートを開いて[拡張機能] → [Apps Script]からGASエディタを開いて以下を貼り付けてください。
setup.gssetupSpreadsheet関数を実行することでスプレッドシートにシートの作成・ヘッダーを設定できます。

Webアプリとしてアクセスできるユーザーを「全員」としてデプロイし、URLをコピーしておいてください。

code.gs
// ============================================
// エンドポイント: GETリクエスト(セグメント取得)
// ============================================
function doGet(e) {
  try {
    const action = e.parameter.action;
    const userId = e.parameter.userId;

    if (action === 'getUserSegments' && userId) {
      const segments = getUserSegments(userId);

      return ContentService
        .createTextOutput(JSON.stringify({
          success: true,
          segments: segments
        }))
        .setMimeType(ContentService.MimeType.JSON);
    }

    return ContentService
      .createTextOutput(JSON.stringify({
        success: false,
        error: 'Invalid action or missing userId'
      }))
      .setMimeType(ContentService.MimeType.JSON);

  } catch (error) {
    Logger.log(error);
    return ContentService
      .createTextOutput(JSON.stringify({
        success: false,
        error: error.toString()
      }))
      .setMimeType(ContentService.MimeType.JSON);
  }
}

// ============================================
// エンドポイント: POSTリクエスト(データ受信)
// ============================================
function doPost(e) {
  try {
    const data = JSON.parse(e.postData.contents);
    const eventType = data.eventType;

    let result;
    switch (eventType) {
      case 'ad_click':
        result = recordAdClick(data);
        break;
      case 'audience':
        result = recordAudience(data);
        break;
      case 'conversion':
        result = recordConversion(data);
        break;
      default:
        throw new Error(`Unknown event type: ${eventType}`);
    }

    return ContentService
      .createTextOutput(JSON.stringify({ success: true, data: result }))
      .setMimeType(ContentService.MimeType.JSON);

  } catch (error) {
    Logger.log(error);
    return ContentService
      .createTextOutput(JSON.stringify({
        success: false,
        error: error.toString()
      }))
      .setMimeType(ContentService.MimeType.JSON);
  }
}

// ============================================
// 広告クリックの記録
// ============================================
function recordAdClick(data) {
  const sheet = getOrCreateSheet('AdClicks');

  sheet.appendRow([
    new Date(),                           // A: タイムスタンプ
    data.clickId,                         // B: クリックID (GCLID相当)
    data.userId,                          // C: ユーザーID
    data.advertiserId || '',              // D: 広告主ID
    data.campaign,                        // E: キャンペーン名
    data.adGroup,                         // F: 広告グループ
    data.keyword,                         // G: 検索キーワード
    data.matchType || 'exact',            // H: マッチタイプ
    data.adRank || 1,                     // I: 広告ランク
    data.cpc || 0,                        // J: クリック単価(CPC)
    data.landingPage || '',               // K: ランディングページURL
    data.device || '',                    // L: デバイスタイプ
    data.audienceList || '',              // M: 適用されたオーディエンスリスト
    data.targetingType || 'keyword',      // N: ターゲティングタイプ
    JSON.stringify(data.metadata || {})   // O: その他のメタデータ
  ]);

  return { clickId: data.clickId };
}

// ============================================
// ユーザー行動(オーディエンス)の記録
// ============================================
function recordAudience(data) {
  const sheet = getOrCreateSheet('Audience');

  sheet.appendRow([
    new Date(),                           // A: タイムスタンプ
    data.userId,                          // B: ユーザーID
    data.sessionId,                       // C: セッションID
    data.advertiserId || '',              // D: 広告主ID
    data.action,                          // E: アクション(page_view/product_view/add_to_cart等)
    data.url || '',                       // F: URL
    data.pageTitle || '',                 // G: ページタイトル
    data.productId || '',                 // H: 商品ID
    data.productName || '',               // I: 商品名
    data.productPrice || '',              // J: 商品価格
    data.category || '',                  // K: カテゴリ
    data.timeOnPage || 0,                 // L: 滞在時間(秒)
    data.scrollDepth || 0,                // M: スクロール深度(%)
    data.referrer || '',                  // N: リファラー
    data.device || '',                    // O: デバイス
    JSON.stringify(data.metadata || {})   // P: その他のメタデータ
  ]);

  // オーディエンスセグメントの判定
  const segments = determineAudienceSegments(data);

  return { segments };
}

// ============================================
// コンバージョンの記録
// ============================================
function recordConversion(data) {
  const sheet = getOrCreateSheet('Conversions');

  // クリックIDからクリックデータを取得
  const clickData = getClickDataByClickId(data.clickId);

  sheet.appendRow([
    new Date(),                           // A: タイムスタンプ
    data.userId,                          // B: ユーザーID
    data.clickId || '',                   // C: クリックID
    data.advertiserId || '',              // D: 広告主ID
    data.conversionType,                  // E: コンバージョンタイプ
    data.conversionValue || 0,            // F: コンバージョン価値
    data.currency || 'JPY',               // G: 通貨
    data.orderId || '',                   // H: 注文ID
    data.productId || '',                 // I: 商品ID
    data.productName || '',               // J: 商品名
    data.quantity || 1,                   // K: 数量

    // アトリビューション情報(クリックデータから取得)
    clickData?.campaign || '',            // L: キャンペーン名
    clickData?.adGroup || '',             // M: 広告グループ
    clickData?.keyword || '',             // N: キーワード
    clickData?.cpc || 0,                  // O: CPC
    clickData?.audienceList || '',        // P: オーディエンスリスト

    // タイムスタンプ差分
    clickData ? calculateTimeDiff(clickData.timestamp, new Date()) : '', // Q: クリックからの経過時間

    JSON.stringify(data.metadata || {})   // R: その他のメタデータ
  ]);

  return {
    conversionId: data.orderId,
    attributed: !!clickData
  };
}

// ============================================
// オーディエンスセグメントの判定
// ============================================
function determineAudienceSegments(data) {
  const segments = [];
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Audience');

  if (!sheet) return segments;

  const userId = data.userId;
  const dataRange = sheet.getDataRange();
  const values = dataRange.getValues();

  // ユーザーの過去の行動を取得(最新100件)
  const userActions = values
    .filter(row => row[1] === userId) // B列: ユーザーID
    .slice(-100);

  // セグメント判定ロジック

  // 1. サイト訪問者(過去30日以内に訪問)
  const recentVisits = userActions.filter(row => {
    const timestamp = new Date(row[0]);
    const daysSince = (Date.now() - timestamp.getTime()) / (1000 * 60 * 60 * 24);
    return daysSince <= 30;
  });
  if (recentVisits.length > 0) {
    segments.push('site_visitors_30d');
  }

  // 2. 商品閲覧者
  const productViews = userActions.filter(row => row[4] === 'product_view'); // E列: アクション
  if (productViews.length > 0) {
    segments.push('product_viewers');
  }

  // 3. カート追加者(コンバージョンに至っていない)
  const cartAdds = userActions.filter(row => row[4] === 'add_to_cart'); // E列: アクション
  if (cartAdds.length > 0) {
    segments.push('cart_abandoners');
  }

  // 4. 高頻度訪問者(過去7日で3回以上)
  const recent7Days = userActions.filter(row => {
    const timestamp = new Date(row[0]);
    const daysSince = (Date.now() - timestamp.getTime()) / (1000 * 60 * 60 * 24);
    return daysSince <= 7;
  });
  if (recent7Days.length >= 3) {
    segments.push('frequent_visitors');
  }

  // 5. 特定カテゴリ興味関心者
  const categoryInterests = userActions
    .filter(row => row[10]) // K列: カテゴリ
    .map(row => row[10]);

  const categoryCount = {};
  categoryInterests.forEach(cat => {
    categoryCount[cat] = (categoryCount[cat] || 0) + 1;
  });

  Object.keys(categoryCount).forEach(category => {
    if (categoryCount[category] >= 2) {
      segments.push(`interest_${category}`);
    }
  });

  // セグメント情報をUserSegmentsシートに保存
  updateUserSegments(data.userId, data.advertiserId || '', segments);

  return segments;
}

// ============================================
// UserSegmentsシートにセグメント情報を保存
// ============================================
function updateUserSegments(userId, advertiserId, segments) {
  const sheet = getOrCreateSheet('UserSegments');
  const values = sheet.getDataRange().getValues();

  // ヘッダー行がある場合を考慮
  const headerRow = values.length > 0 && values[0][0] === 'ユーザーID' ? 1 : 0;

  // ユーザーIDで既存行を検索
  let rowIndex = -1;
  for (let i = headerRow; i < values.length; i++) {
    if (values[i][0] === userId) {
      rowIndex = i + 1; // Spreadsheetは1-indexed
      break;
    }
  }

  const segmentString = segments.join(',');
  const now = new Date();

  if (rowIndex > 0) {
    // 既存行を更新
    sheet.getRange(rowIndex, 2).setValue(advertiserId);
    sheet.getRange(rowIndex, 3).setValue(segmentString);
    sheet.getRange(rowIndex, 4).setValue(now);
  } else {
    // 新規行を追加
    sheet.appendRow([userId, advertiserId, segmentString, now]);
  }
}

// ============================================
// UserSegmentsシートからセグメント情報を取得
// ============================================
function getUserSegments(userId) {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('UserSegments');

  if (!sheet) {
    return [];
  }

  const values = sheet.getDataRange().getValues();

  // ヘッダー行がある場合を考慮
  const headerRow = values.length > 0 && values[0][0] === 'ユーザーID' ? 1 : 0;

  // ユーザーIDで検索
  for (let i = headerRow; i < values.length; i++) {
    if (values[i][0] === userId) {
      const segmentString = values[i][2]; // カラム2(0-indexed)はセグメント
      // カンマ区切りの文字列を配列に変換
      return segmentString ? segmentString.split(',').filter(s => s) : [];
    }
  }

  return [];
}

// ============================================
// クリックIDからクリックデータを取得
// ============================================
function getClickDataByClickId(clickId) {
  if (!clickId) return null;

  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('AdClicks');
  if (!sheet) return null;

  const dataRange = sheet.getDataRange();
  const values = dataRange.getValues();

  // クリックIDで検索(B列)
  for (let i = values.length - 1; i >= 0; i--) {
    if (values[i][1] === clickId) {
      return {
        timestamp: values[i][0],
        clickId: values[i][1],
        userId: values[i][2],
        advertiserId: values[i][3],
        campaign: values[i][4],
        adGroup: values[i][5],
        keyword: values[i][6],
        matchType: values[i][7],
        adRank: values[i][8],
        cpc: values[i][9],
        landingPage: values[i][10],
        device: values[i][11],
        audienceList: values[i][12],
        targetingType: values[i][13]
      };
    }
  }

  return null;
}

// ============================================
// 時間差分の計算
// ============================================
function calculateTimeDiff(start, end) {
  const diff = end.getTime() - new Date(start).getTime();
  const hours = Math.floor(diff / (1000 * 60 * 60));
  const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
  return `${hours}時間${minutes}分`;
}
setup.gs
setup.gs
// ============================================
// シートの取得または作成
// ============================================
function getOrCreateSheet(sheetName) {
  const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  let sheet = spreadsheet.getSheetByName(sheetName);

  if (!sheet) {
    sheet = spreadsheet.insertSheet(sheetName);
    setupSheetHeaders(sheet, sheetName);
  }

  return sheet;
}

// ============================================
// シートヘッダーの設定
// ============================================
function setupSheetHeaders(sheet, sheetName) {
  let headers = [];

  switch (sheetName) {
    case 'AdClicks':
      headers = [
        'タイムスタンプ',
        'クリックID',
        'ユーザーID',
        '広告主ID',
        'キャンペーン名',
        '広告グループ',
        '検索キーワード',
        'マッチタイプ',
        '広告ランク',
        'CPC',
        'ランディングページ',
        'デバイス',
        'オーディエンスリスト',
        'ターゲティングタイプ',
        'メタデータ'
      ];
      break;

    case 'Audience':
      headers = [
        'タイムスタンプ',
        'ユーザーID',
        'セッションID',
        '広告主ID',
        'アクション',
        'URL',
        'ページタイトル',
        '商品ID',
        '商品名',
        '商品価格',
        'カテゴリ',
        '滞在時間',
        'スクロール深度',
        'リファラー',
        'デバイス',
        'メタデータ'
      ];
      break;

    case 'Conversions':
      headers = [
        'タイムスタンプ',
        'ユーザーID',
        'クリックID',
        '広告主ID',
        'コンバージョンタイプ',
        'コンバージョン価値',
        '通貨',
        '注文ID',
        '商品ID',
        '商品名',
        '数量',
        'キャンペーン名',
        '広告グループ',
        'キーワード',
        'CPC',
        'オーディエンスリスト',
        'クリックからの経過時間',
        'メタデータ'
      ];
      break;

    case 'UserSegments':
      headers = [
        'ユーザーID',
        '広告主ID',
        'セグメント',
        '更新日時'
      ];
      break;
  }

  if (headers.length > 0) {
    sheet.appendRow(headers);

    // ヘッダー行のスタイル設定
    const headerRange = sheet.getRange(1, 1, 1, headers.length);
    headerRange.setFontWeight('bold');
    headerRange.setBackground('#4285f4');
    headerRange.setFontColor('#ffffff');
    headerRange.setWrap(true);

    // 列幅の自動調整
    sheet.autoResizeColumns(1, headers.length);
  }
}

// ============================================
// 初期セットアップ関数(手動実行用)
// ============================================
function setupSpreadsheet() {
  getOrCreateSheet('AdClicks');
  getOrCreateSheet('Audience');
  getOrCreateSheet('Conversions');
  getOrCreateSheet('UserSegments');

  Browser.msgBox('スプレッドシートのセットアップが完了しました!');
}

フロントエンド(広告タグ)

demo-tag.js

GASの公開URLをCONFIG変数のendpointに設定してください。

demo-tag.js
(function() {
  'use strict';

  // ============================================
  // 設定
  // ============================================
  const CONFIG = {
    endpoint: '<GASデプロイURL>',
    clickIdCookieName: 'democlid',     // 広告クリックID(Google広告のgclidに相当)
    clickIdParam: 'democlid',          // URLパラメータ名
    userIdCookieName: 'demo_uid',      // ユーザーID
    sessionIdStorageKey: 'demo_sid',   // セッションID
    clickIdExpireDays: 90,             // クリックIDの有効期限(90日)
    cookieExpireDays: 365,             // ユーザーIDの有効期限(365日)
    advertiserId: null                 // 広告主ID(dtag('config', 'DEMO-XXX')で設定)
  };

  // ============================================
  // グローバル変数
  // ============================================
  const pageLoadTime = Date.now();
  let maxScrollDepth = 0;
  let isConfigured = false;

  // ============================================
  // ユーティリティ関数
  // ============================================
  function generateId() {
    return Math.random().toString(36).substr(2, 9) + Date.now().toString(36);
  }

  function getCookie(name) {
    const value = `; ${document.cookie}`;
    const parts = value.split(`; ${name}=`);
    if (parts.length === 2) return parts.pop().split(';').shift();
  }

  function setCookie(name, value, days) {
    const expires = new Date(Date.now() + days * 864e5).toUTCString();
    document.cookie = `${name}=${value}; expires=${expires}; path=/; SameSite=Lax`;
  }

  function getUserId() {
    let userId = getCookie(CONFIG.userIdCookieName);
    if (!userId) {
      userId = 'uid_' + generateId();
      setCookie(CONFIG.userIdCookieName, userId, CONFIG.cookieExpireDays);
    }
    return userId;
  }

  function getSessionId() {
    let sessionId = sessionStorage.getItem(CONFIG.sessionIdStorageKey);
    if (!sessionId) {
      sessionId = 'sid_' + generateId();
      sessionStorage.setItem(CONFIG.sessionIdStorageKey, sessionId);
    }
    return sessionId;
  }

  function getClickId() {
    return getCookie(CONFIG.clickIdCookieName);
  }

  function getDevice() {
    const ua = navigator.userAgent;
    if (/mobile/i.test(ua)) return 'mobile';
    if (/tablet|ipad/i.test(ua)) return 'tablet';
    return 'desktop';
  }

  function getUrlParameter(name) {
    const urlParams = new URLSearchParams(window.location.search);
    return urlParams.get(name);
  }

  // ============================================
  // データ送信
  // ============================================
  function sendData(payload) {
    if (navigator.sendBeacon) {
      navigator.sendBeacon(CONFIG.endpoint, JSON.stringify(payload));
    } else {
      fetch(CONFIG.endpoint, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify(payload),
        keepalive: true
      }).catch(err => console.error('送信エラー:', err));
    }
  }

  // ============================================
  // 広告クリックIDの保存
  // ============================================
  function saveClickId() {
    const clickId = getUrlParameter(CONFIG.clickIdParam);
    if (clickId) {
      // クリックIDをCookieに保存(ラストクリック方式)
      setCookie(CONFIG.clickIdCookieName, clickId, CONFIG.clickIdExpireDays);

      // 広告クリックイベントをバックエンドに送信
      const urlParams = new URLSearchParams(window.location.search);

      sendData({
        eventType: 'ad_click',
        clickId: clickId,
        userId: getUserId(),
        advertiserId: CONFIG.advertiserId,
        campaign: urlParams.get('campaign') || '',
        adGroup: urlParams.get('adgroup') || '',
        keyword: urlParams.get('keyword') || '',
        matchType: urlParams.get('matchtype') || 'exact',
        landingPage: window.location.href,
        device: getDevice(),
        timestamp: new Date().toISOString()
      });
    }
  }

  // ============================================
  // ページビュートラッキング
  // ============================================
  function trackPageView() {
    const startTime = Date.now();
    let pageViewSent = false; // 重複送信を防ぐフラグ

    // スクロール深度の計測
    const updateMaxScroll = () => {
      const scrollPercent = Math.round(
        (window.scrollY + window.innerHeight) / document.documentElement.scrollHeight * 100
      );
      maxScrollDepth = Math.max(maxScrollDepth, scrollPercent);
    };

    window.addEventListener('scroll', updateMaxScroll, { passive: true });

    // ページ離脱時に送信
    const sendPageViewData = () => {
      // 既に送信済みの場合はスキップ(beforeunloadとpagehideの両方が発火するのを防ぐ)
      if (pageViewSent) return;
      pageViewSent = true;

      const timeOnPage = Math.round((Date.now() - startTime) / 1000);

      sendData({
        eventType: 'audience',
        userId: getUserId(),
        sessionId: getSessionId(),
        advertiserId: CONFIG.advertiserId,
        action: 'page_view',
        url: window.location.href,
        pageTitle: document.title,
        referrer: document.referrer,
        timeOnPage: timeOnPage,
        scrollDepth: maxScrollDepth,
        device: getDevice(),
        timestamp: new Date().toISOString()
      });
    };

    // 両方のイベントに対応(ブラウザによって発火するイベントが異なるため)
    window.addEventListener('beforeunload', sendPageViewData);
    window.addEventListener('pagehide', sendPageViewData);
  }

  // ============================================
  // 商品閲覧トラッキング
  // ============================================
  function trackProductView() {
    // data属性から商品情報を取得
    const productElement = document.querySelector('[data-product-id]');
    if (!productElement) return;

    sendData({
      eventType: 'audience',
      userId: getUserId(),
      sessionId: getSessionId(),
      advertiserId: CONFIG.advertiserId,
      action: 'product_view',
      url: window.location.href,
      pageTitle: document.title,
      productId: productElement.dataset.productId,
      productName: productElement.dataset.productName || '',
      productPrice: productElement.dataset.productPrice || '',
      category: productElement.dataset.category || '',
      device: getDevice(),
      timestamp: new Date().toISOString()
    });
  }

  // ============================================
  // カート追加トラッキング
  // ============================================
  function setupAddToCartTracking() {
    document.addEventListener('click', (e) => {
      const target = e.target.closest('[data-add-to-cart]');
      if (target) {
        sendData({
          eventType: 'audience',
          userId: getUserId(),
          sessionId: getSessionId(),
          advertiserId: CONFIG.advertiserId,
          action: 'add_to_cart',
          url: window.location.href,
          productId: target.dataset.productId || '',
          productName: target.dataset.productName || '',
          productPrice: target.dataset.productPrice || '',
          quantity: parseInt(target.dataset.quantity || '1'),
          device: getDevice(),
          timestamp: new Date().toISOString()
        });
      }
    });
  }

  // ============================================
  // コンバージョン送信
  // ============================================
  function sendConversion(eventData) {
    const clickId = getClickId();

    const payload = {
      eventType: 'conversion',
      userId: getUserId(),
      advertiserId: CONFIG.advertiserId,
      clickId: clickId || null,
      conversionType: eventData.conversionType || eventData.send_to || 'purchase',
      conversionValue: eventData.value || 0,
      currency: eventData.currency || 'JPY',
      orderId: eventData.transaction_id || eventData.orderId || '',
      productId: eventData.productId || '',
      productName: eventData.productName || '',
      quantity: eventData.quantity || 1,
      timestamp: new Date().toISOString(),
      metadata: eventData.metadata || {}
    };

    sendData(payload);
  }

  // ============================================
  // カスタムイベント送信
  // ============================================
  function sendCustomEvent(eventName, eventData) {
    sendData({
      eventType: 'audience',
      userId: getUserId(),
      sessionId: getSessionId(),
      advertiserId: CONFIG.advertiserId,
      action: eventName,
      url: window.location.href,
      pageTitle: document.title,
      device: getDevice(),
      timestamp: new Date().toISOString(),
      ...eventData
    });
  }

  // ============================================
  // dtag() 関数(メインAPI)
  // ============================================
  function dtag(command, ...args) {
    switch (command) {
      case 'config':
        // 初期化
        // 使用例: dtag('config', 'DEMO-12345')
        CONFIG.advertiserId = args[0];
        isConfigured = true;

        // 自動トラッキング開始
        if (!window.dtagInitialized) {
          saveClickId();
          trackPageView();
          trackProductView();
          setupAddToCartTracking();
          window.dtagInitialized = true;
        }
        break;

      case 'event':
        // イベント送信
        // 使用例: dtag('event', 'conversion', { value: 1000, currency: 'JPY' })
        const eventName = args[0];
        const eventData = args[1] || {};

        if (eventName === 'conversion') {
          sendConversion(eventData);
        } else {
          sendCustomEvent(eventName, eventData);
        }
        break;

      case 'get':
        // データ取得
        // 使用例: dtag('get', 'user_id', callback)
        const key = args[0];
        const callback = args[1];

        if (callback && typeof callback === 'function') {
          switch (key) {
            case 'user_id':
              callback(getUserId());
              break;
            case 'click_id':
              callback(getClickId());
              break;
            case 'session_id':
              callback(getSessionId());
              break;
            default:
              callback(null);
          }
        }
        break;

      case 'set':
        // 設定の更新
        // 使用例: dtag('set', { endpoint: 'https://...' })
        const settings = args[0] || {};
        Object.assign(CONFIG, settings);
        break;

      default:
        console.warn('Unknown dtag command:', command);
    }
  }

  // ============================================
  // グローバル関数のエクスポート
  // ============================================
  window.dtag = dtag;

  // Data Layerの初期化(gtag.jsと同様)
  window.dataLayer = window.dataLayer || [];

  // 既にキューに入っているコマンドを実行
  if (window.dataLayer.length > 0) {
    window.dataLayer.forEach(args => {
      dtag.apply(null, args);
    });
  }

  // dataLayer.push() をdtag()にリダイレクト
  const originalPush = window.dataLayer.push;
  window.dataLayer.push = function(...args) {
    dtag.apply(null, args[0]);
    return originalPush.apply(window.dataLayer, args);
  };

})();

サンプル画面

demo-tag.jsの読み込みパスを適宜修正してご使用ください。

検索画面

GASの公開URLをCONFIG変数のendpointに設定してください。

search-ads.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>検索結果 - デモ検索エンジン</title>
  <style>
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
    }

    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
      background-color: #f8f9fa;
      padding: 20px;
    }

    .container {
      max-width: 800px;
      margin: 0 auto;
    }

    header {
      background: white;
      padding: 20px;
      border-radius: 8px;
      margin-bottom: 20px;
      box-shadow: 0 1px 3px rgba(0,0,0,0.1);
    }

    h1 {
      font-size: 24px;
      color: #4285f4;
      margin-bottom: 15px;
    }

    .search-box {
      display: flex;
      gap: 10px;
    }

    #searchInput {
      flex: 1;
      padding: 12px;
      border: 1px solid #ddd;
      border-radius: 4px;
      font-size: 16px;
    }

    .search-btn {
      padding: 12px 24px;
      background: #4285f4;
      color: white;
      border: none;
      border-radius: 4px;
      cursor: pointer;
      font-size: 16px;
    }

    .search-btn:hover {
      background: #357ae8;
    }

    .ads-section {
      background: #fff9e6;
      border: 1px solid #f4e4a6;
      border-radius: 8px;
      padding: 20px;
      margin-bottom: 20px;
    }

    .ads-label {
      font-size: 12px;
      color: #666;
      font-weight: bold;
      margin-bottom: 10px;
    }

    .ad-item {
      background: white;
      border: 1px solid #e0e0e0;
      border-radius: 8px;
      padding: 16px;
      margin-bottom: 12px;
      cursor: pointer;
      transition: box-shadow 0.2s;
    }

    .ad-item:hover {
      box-shadow: 0 2px 8px rgba(0,0,0,0.1);
    }

    .ad-title {
      font-size: 18px;
      color: #1a0dab;
      margin-bottom: 6px;
      font-weight: 500;
    }

    .ad-url {
      font-size: 14px;
      color: #006621;
      margin-bottom: 8px;
    }

    .ad-description {
      font-size: 14px;
      color: #545454;
      line-height: 1.5;
    }

    .ad-badge {
      display: inline-block;
      background: #4285f4;
      color: white;
      padding: 2px 8px;
      border-radius: 3px;
      font-size: 11px;
      margin-bottom: 8px;
      font-weight: bold;
    }

    .organic-section {
      background: white;
      border-radius: 8px;
      padding: 20px;
      box-shadow: 0 1px 3px rgba(0,0,0,0.1);
    }

    .organic-item {
      padding: 16px 0;
      border-bottom: 1px solid #eee;
    }

    .organic-item:last-child {
      border-bottom: none;
    }

    .organic-title {
      font-size: 18px;
      color: #1a0dab;
      margin-bottom: 4px;
    }

    .organic-url {
      font-size: 14px;
      color: #006621;
      margin-bottom: 8px;
    }

    .organic-description {
      font-size: 14px;
      color: #545454;
      line-height: 1.5;
    }

    .targeting-info {
      background: #e8f4fd;
      border-left: 4px solid #4285f4;
      padding: 12px;
      margin-top: 10px;
      border-radius: 4px;
      font-size: 13px;
      color: #333;
    }

    .targeting-badge {
      display: inline-block;
      background: #34a853;
      color: white;
      padding: 3px 8px;
      border-radius: 3px;
      font-size: 11px;
      margin-right: 5px;
      margin-top: 5px;
    }

    .info-panel {
      background: white;
      border-radius: 8px;
      padding: 20px;
      margin-bottom: 20px;
      box-shadow: 0 1px 3px rgba(0,0,0,0.1);
    }

    .info-panel h3 {
      font-size: 16px;
      margin-bottom: 10px;
      color: #333;
    }

    .info-panel p {
      font-size: 14px;
      color: #666;
      line-height: 1.6;
    }

    code {
      background: #f5f5f5;
      padding: 2px 6px;
      border-radius: 3px;
      font-family: 'Courier New', monospace;
      font-size: 13px;
    }
  </style>
</head>
<body>
  <div class="container">
    <header>
      <h1>🔍 デモ検索エンジン</h1>
      <div class="search-box">
        <input type="text" id="searchInput" placeholder="検索キーワードを入力..." value="ノートパソコン">
        <button class="search-btn" onclick="performSearch()">検索</button>
      </div>
    </header>

    <div class="info-panel">
      <h3>📊 この検索結果について</h3>
      <p>
        この検索結果ページは、検索広告の仕組みを学ぶためのシミュレーターです。
        広告をクリックすると、クリックID(DEMOCLID)が生成され、ランディングページに遷移します。
        <br><br>
        <strong>広告表示ロジック:</strong><br>
        ・キーワードターゲティング: 検索キーワードと広告のマッチング<br>
        ・オーディエンスターゲティング: 過去の行動履歴に基づく配信<br>
        ・広告ランク: 入札価格 × 品質スコアで順位決定
      </p>
    </div>

    <div class="ads-section" id="adsSection">
      <div class="ads-label">広告</div>
      <div id="adsContainer"></div>
    </div>

    <div class="organic-section">
      <div class="organic-item">
        <div class="organic-title">【公式】ノートパソコン専門店 - デモPC通販</div>
        <div class="organic-url">https://www.demo-pc-shop.com/</div>
        <div class="organic-description">
          豊富な品揃えのノートパソコン専門店。最新モデルから格安中古PCまで、あなたにぴったりの一台が見つかります。
        </div>
      </div>

      <div class="organic-item">
        <div class="organic-title">2024年版 ノートパソコンの選び方ガイド</div>
        <div class="organic-url">https://www.tech-guide.com/laptop-guide</div>
        <div class="organic-description">
          初心者でもわかるノートパソコンの選び方を徹底解説。CPU、メモリ、ストレージの見方から、用途別のおすすめモデルまで。
        </div>
      </div>

      <div class="organic-item">
        <div class="organic-title">価格.com - ノートパソコン 人気ランキング</div>
        <div class="organic-url">https://kakaku.com/pc/note-pc/</div>
        <div class="organic-description">
          ノートパソコンの最安値を比較。人気ランキング、スペック検索、クチコミ情報など、購入に役立つ情報が満載。
        </div>
      </div>
    </div>
  </div>

  <script>
    // ============================================
    // 設定
    // ============================================
    const CONFIG = {
      gasEndpoint: '<GASのエンドポイントURL>',
      userIdCookieName: 'demo_uid'
    };

    // ============================================
    // 広告データベース
    // ============================================
    const adsDatabase = [
      {
        id: 'ad001',
        title: '【公式】メーカーA ノートPC 最大30%OFF',
        url: 'www.maker-a.com/notebooks',
        description: '高性能ノートPCが今ならお得。ビジネスにも最適な信頼のメーカーA製品を今すぐチェック。送料無料・24時間サポート付き。',
        keywords: ['ノートパソコン', 'ノートPC', 'パソコン', 'laptop'],
        campaign: '春の新生活キャンペーン',
        adGroup: 'ノートPC_汎用',
        cpc: 150,
        qualityScore: 8,
        targetingType: 'keyword',
        audienceList: []
      },
      {
        id: 'ad002',
        title: 'メーカーB ノートPC - 薄く、軽く、速い',
        url: 'www.maker-b.com/notebooks',
        description: '最新プロセッサ搭載で驚異的なパフォーマンス。最大18時間のバッテリー駆動。学生・教職員向け特別価格あり。',
        keywords: ['ノートパソコン', 'ノートPC'],
        campaign: 'ノートPC_2024',
        adGroup: 'ノートPC_薄型',
        cpc: 200,
        qualityScore: 9,
        targetingType: 'keyword',
        audienceList: []
      },
      {
        id: 'ad003',
        title: 'あなたが見た商品が特別価格に!',
        url: 'www.pc-retargeting.com/special',
        description: '先日ご覧になった商品が期間限定で15%OFF。在庫わずか、今すぐチェック。',
        keywords: [],
        campaign: 'リマーケティング',
        adGroup: 'カート放棄者',
        cpc: 180,
        qualityScore: 7,
        targetingType: 'remarketing',
        audienceList: ['cart_abandoners', 'product_viewers']
      },
      {
        id: 'ad004',
        title: 'ゲーミングノートPC専門店 - 高性能GPU搭載',
        url: 'www.gaming-pc.com',
        description: 'RTX 4070搭載モデルが159,800円から。プロゲーマー推奨のハイスペックマシンで勝利を掴め。',
        keywords: ['ゲーミングPC', 'ゲーミングノート', 'gaming laptop'],
        campaign: 'ゲーミングPC特集',
        adGroup: 'ゲーミング_汎用',
        cpc: 170,
        qualityScore: 8,
        targetingType: 'keyword',
        audienceList: []
      }
    ];

    // ============================================
    // 広告表示ロジック
    // ============================================
    async function displayAds() {
      const searchKeyword = document.getElementById('searchInput').value.toLowerCase();
      const userSegments = await getUserSegments(); // GASからオーディエンスセグメントを取得

      // 広告のマッチングとランク計算
      const matchedAds = adsDatabase
        .map(ad => {
          let relevanceScore = 0;

          // 1. キーワードマッチング
          const keywordMatch = ad.keywords.some(kw =>
            searchKeyword.includes(kw.toLowerCase()) || kw.toLowerCase().includes(searchKeyword)
          );
          if (keywordMatch) {
            relevanceScore += 10;
          }

          // 2. オーディエンスマッチング(リマーケティング)
          const audienceMatch = ad.audienceList.some(audience =>
            userSegments.includes(audience)
          );
          if (audienceMatch) {
            relevanceScore += 15; // オーディエンスマッチは高スコア
          }

          // 3. 広告ランク計算(CPC × 品質スコア × 関連性)
          const adRank = ad.cpc * ad.qualityScore * (relevanceScore > 0 ? relevanceScore : 0.1);

          return {
            ...ad,
            relevanceScore,
            adRank,
            matched: relevanceScore > 0
          };
        })
        .filter(ad => ad.matched) // マッチした広告のみ
        .sort((a, b) => b.adRank - a.adRank) // 広告ランクで降順ソート
        .slice(0, 3); // 上位3件

      // 広告を表示
      const adsContainer = document.getElementById('adsContainer');
      adsContainer.innerHTML = '';

      if (matchedAds.length === 0) {
        adsContainer.innerHTML = '<p style="color: #666;">この検索キーワードに対する広告はありません。</p>';
        return;
      }

      matchedAds.forEach((ad, index) => {
        const adElement = document.createElement('div');
        adElement.className = 'ad-item';
        adElement.innerHTML = `
          <div class="ad-badge">広告</div>
          <div class="ad-title">${ad.title}</div>
          <div class="ad-url">${ad.url}</div>
          <div class="ad-description">${ad.description}</div>
          <div class="targeting-info">
            <strong>📌 ターゲティング情報:</strong><br>
            キャンペーン: ${ad.campaign} | 広告グループ: ${ad.adGroup} | CPC: ¥${ad.cpc} | 品質スコア: ${ad.qualityScore}/10<br>
            広告ランク: ${Math.round(ad.adRank)} | タイプ: ${ad.targetingType === 'remarketing' ? 'リマーケティング' : 'キーワード'}
            ${ad.relevanceScore > 10 ? '<br><span class="targeting-badge">オーディエンスマッチ</span>' : ''}
          </div>
        `;

        adElement.onclick = () => clickAd(ad, index + 1);
        adsContainer.appendChild(adElement);
      });
    }

    // ============================================
    // 広告クリック処理
    // ============================================
    function clickAd(ad, position) {
      // クリックID生成(GCLID相当)
      const clickId = generateClickId();

      // ランディングページURLを構築
      const landingPageUrl = new URL('landing.html', window.location.href);
      landingPageUrl.searchParams.set('democlid', clickId);
      landingPageUrl.searchParams.set('campaign', ad.campaign);
      landingPageUrl.searchParams.set('adgroup', ad.adGroup);
      landingPageUrl.searchParams.set('keyword', document.getElementById('searchInput').value);
      landingPageUrl.searchParams.set('matchtype', 'broad');

      console.log('広告クリック:', {
        clickId,
        campaign: ad.campaign,
        adGroup: ad.adGroup,
        position: position
      });

      // ランディングページに遷移
      window.location.href = landingPageUrl.toString();
    }

    // ============================================
    // クリックID生成
    // ============================================
    function generateClickId() {
      const timestamp = Date.now().toString(36);
      const random = Math.random().toString(36).substr(2, 9);
      return `EAIaI${timestamp}${random}`;
    }

    // ============================================
    // ユーザーID取得(Cookieから読み取り)
    // ============================================
    function getUserId() {
      const value = `; ${document.cookie}`;
      const parts = value.split(`; ${CONFIG.userIdCookieName}=`);
      if (parts.length === 2) {
        return parts.pop().split(';').shift();
      }
      return null;
    }

    // ============================================
    // オーディエンスセグメント取得(GASから取得)
    // ============================================
    async function getUserSegments() {
      const userId = getUserId();
      console.log('[DEBUG] getUserSegments - userId:', userId);

      if (!userId) {
        console.log('[DEBUG] ユーザーIDが取得できませんでした');
        return [];
      }

      try {
        // GASのエンドポイントにGETリクエスト
        const url = `${CONFIG.gasEndpoint}?action=getUserSegments&userId=${encodeURIComponent(userId)}`;
        console.log('[DEBUG] GETリクエスト URL:', url);

        const response = await fetch(url);
        console.log('[DEBUG] レスポンスステータス:', response.status);

        const data = await response.json();
        console.log('[DEBUG] レスポンスデータ:', data);

        if (data.success) {
          console.log('[DEBUG] 取得したセグメント:', data.segments);
          return data.segments || [];
        } else {
          console.error('セグメント取得エラー:', data.error);
          return [];
        }
      } catch (error) {
        console.error('セグメント取得エラー:', error);
        return [];
      }
    }

    // ============================================
    // 検索実行
    // ============================================
    async function performSearch() {
      await displayAds();
    }

    // ============================================
    // 初期化
    // ============================================
    document.addEventListener('DOMContentLoaded', async () => {
      await displayAds();

      // Enterキーで検索
      document.getElementById('searchInput').addEventListener('keypress', async (e) => {
        if (e.key === 'Enter') {
          await performSearch();
        }
      });
    });
  </script>
</body>
</html>
ランディングページ
landing.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Dell公式 - ノートPC特別キャンペーン</title>
  <style>
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
    }

    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
      background: #f5f5f5;
    }

    header {
      background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
      color: white;
      padding: 30px 20px;
      text-align: center;
    }

    header h1 {
      font-size: 36px;
      margin-bottom: 10px;
    }

    header p {
      font-size: 18px;
      opacity: 0.9;
    }

    .container {
      max-width: 1200px;
      margin: 0 auto;
      padding: 40px 20px;
    }

    .info-banner {
      background: #fff3cd;
      border-left: 4px solid #ffc107;
      padding: 15px 20px;
      margin-bottom: 30px;
      border-radius: 4px;
    }

    .info-banner strong {
      color: #856404;
    }

    .products {
      display: grid;
      grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
      gap: 30px;
      margin-bottom: 40px;
    }

    .product-card {
      background: white;
      border-radius: 12px;
      overflow: hidden;
      box-shadow: 0 4px 6px rgba(0,0,0,0.1);
      transition: transform 0.3s, box-shadow 0.3s;
      cursor: pointer;
    }

    .product-card:hover {
      transform: translateY(-5px);
      box-shadow: 0 8px 15px rgba(0,0,0,0.2);
    }

    .product-image {
      width: 100%;
      height: 200px;
      background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
      display: flex;
      align-items: center;
      justify-content: center;
      font-size: 60px;
    }

    .product-info {
      padding: 20px;
    }

    .product-name {
      font-size: 20px;
      font-weight: bold;
      margin-bottom: 10px;
      color: #333;
    }

    .product-price {
      font-size: 28px;
      font-weight: bold;
      color: #e74c3c;
      margin-bottom: 10px;
    }

    .product-price-original {
      font-size: 16px;
      text-decoration: line-through;
      color: #999;
      margin-left: 10px;
    }

    .product-features {
      font-size: 14px;
      color: #666;
      line-height: 1.8;
      margin-bottom: 15px;
    }

    .product-features li {
      margin-left: 20px;
    }

    .btn {
      display: block;
      width: 100%;
      padding: 15px;
      background: #667eea;
      color: white;
      text-align: center;
      border: none;
      border-radius: 8px;
      font-size: 16px;
      font-weight: bold;
      cursor: pointer;
      transition: background 0.3s;
      text-decoration: none;
    }

    .btn:hover {
      background: #5568d3;
    }

    .tracking-info {
      background: white;
      border-radius: 12px;
      padding: 25px;
      margin-top: 30px;
      box-shadow: 0 4px 6px rgba(0,0,0,0.1);
    }

    .tracking-info h3 {
      font-size: 20px;
      margin-bottom: 15px;
      color: #333;
    }

    .tracking-info table {
      width: 100%;
      border-collapse: collapse;
    }

    .tracking-info td {
      padding: 10px;
      border-bottom: 1px solid #eee;
      font-size: 14px;
    }

    .tracking-info td:first-child {
      font-weight: bold;
      color: #666;
      width: 30%;
    }

    .tracking-info code {
      background: #f5f5f5;
      padding: 3px 8px;
      border-radius: 4px;
      font-family: 'Courier New', monospace;
      font-size: 13px;
      color: #e74c3c;
    }

    .badge {
      display: inline-block;
      background: #28a745;
      color: white;
      padding: 4px 12px;
      border-radius: 20px;
      font-size: 12px;
      font-weight: bold;
      margin-left: 10px;
    }
  </style>
</head>
<body>
  <header>
    <h1>🎯 春の新生活応援セール</h1>
    <p>人気のノートPCが最大30%OFF!今だけの特別価格</p>
  </header>

  <div class="container">
    <div class="info-banner" id="infoBanner" style="display: none;">
      <strong>📌 広告クリックが記録されました!</strong>
      このページは広告経由で訪問されています。クリックIDがCookieに保存され、コンバージョン時に自動的に紐付けられます。
    </div>

    <div class="products">
      <div class="product-card" onclick="goToProduct('prod001')">
        <div class="product-image">💻</div>
        <div class="product-info">
          <div class="product-name">ノートPC A1 13インチモデル</div>
          <div class="product-price">
            ¥169,800
            <span class="product-price-original">¥199,800</span>
            <span class="badge">30%OFF</span>
          </div>
          <ul class="product-features">
            <li>13.4インチ OLED ディスプレイ</li>
            <li>Intel Core i7-1360P</li>
            <li>16GB RAM / 512GB SSD</li>
            <li>約1.24kg 超軽量</li>
          </ul>
          <a href="#" class="btn" onclick="event.preventDefault(); goToProduct('prod001');">詳細を見る</a>
        </div>
      </div>

      <div class="product-card" onclick="goToProduct('prod002')">
        <div class="product-image">🖥️</div>
        <div class="product-info">
          <div class="product-name">ノートPC B1 15インチモデル</div>
          <div class="product-price">
            ¥89,800
            <span class="product-price-original">¥119,800</span>
            <span class="badge">25%OFF</span>
          </div>
          <ul class="product-features">
            <li>15.6インチ FHD ディスプレイ</li>
            <li>Intel Core i5-1235U</li>
            <li>8GB RAM / 256GB SSD</li>
            <li>コスパ最強モデル</li>
          </ul>
          <a href="#" class="btn" onclick="event.preventDefault(); goToProduct('prod002');">詳細を見る</a>
        </div>
      </div>

      <div class="product-card" onclick="goToProduct('prod003')">
        <div class="product-image"></div>
        <div class="product-info">
          <div class="product-name">ゲーミングPC C1</div>
          <div class="product-price">
            ¥159,800
            <span class="product-price-original">¥189,800</span>
            <span class="badge">20%OFF</span>
          </div>
          <ul class="product-features">
            <li>15.6インチ 165Hz ディスプレイ</li>
            <li>Intel Core i7 + RTX 4060</li>
            <li>16GB RAM / 512GB SSD</li>
            <li>ゲームも快適にプレイ</li>
          </ul>
          <a href="#" class="btn" onclick="event.preventDefault(); goToProduct('prod003');">詳細を見る</a>
        </div>
      </div>
    </div>

    <div class="tracking-info">
      <h3>🔍 トラッキング情報(開発者向け)</h3>
      <table>
        <tr>
          <td>クリックID (democlid):</td>
          <td><code id="clickIdDisplay">-</code></td>
        </tr>
        <tr>
          <td>キャンペーン:</td>
          <td id="campaignDisplay">-</td>
        </tr>
        <tr>
          <td>広告グループ:</td>
          <td id="adGroupDisplay">-</td>
        </tr>
        <tr>
          <td>検索キーワード:</td>
          <td id="keywordDisplay">-</td>
        </tr>
        <tr>
          <td>ユーザーID:</td>
          <td><code id="userIdDisplay">-</code></td>
        </tr>
        <tr>
          <td>オーディエンスセグメント:</td>
          <td id="segmentsDisplay">-</td>
        </tr>
      </table>
    </div>
  </div>

  <!-- デモ広告タグ(ベースタグ) -->
  <script src="../frontend/demo-tag.js"></script>
  <script>
    // タグ初期化
    dtag('config', 'DEMO-ADVERTISER-001');
  </script>

  <script>
    // ページ読み込み時にトラッキング情報を表示
    document.addEventListener('DOMContentLoaded', () => {
      const urlParams = new URLSearchParams(window.location.search);

      // 広告経由かチェック
      const hasAdClick = urlParams.has('democlid');
      if (hasAdClick) {
        document.getElementById('infoBanner').style.display = 'block';
      }

      // クリックID
      dtag('get', 'click_id', (clickId) => {
        const democlid = urlParams.get('democlid') || clickId || '-';
        document.getElementById('clickIdDisplay').textContent = democlid;
      });

      // キャンペーン情報
      document.getElementById('campaignDisplay').textContent = urlParams.get('campaign') || '-';
      document.getElementById('adGroupDisplay').textContent = urlParams.get('adgroup') || '-';
      document.getElementById('keywordDisplay').textContent = urlParams.get('keyword') || '-';

      // ユーザーID
      dtag('get', 'user_id', (userId) => {
        document.getElementById('userIdDisplay').textContent = userId;
      });

      // オーディエンスセグメント(バックエンドで自動管理)
      document.getElementById('segmentsDisplay').textContent = 'バックエンドで管理';
    });

    // 商品詳細ページへ遷移
    function goToProduct(productId) {
      window.location.href = `product.html?id=${productId}`;
    }
  </script>
</body>
</html>
商品詳細ページ
product.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>デモPC - 商品詳細</title>
  <style>
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
    }

    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
      background: #f5f5f5;
    }

    header {
      background: white;
      box-shadow: 0 2px 4px rgba(0,0,0,0.1);
      padding: 20px;
    }

    header h1 {
      font-size: 24px;
      color: #333;
    }

    .container {
      max-width: 1200px;
      margin: 30px auto;
      padding: 0 20px;
    }

    .product-detail {
      display: grid;
      grid-template-columns: 1fr 1fr;
      gap: 40px;
      background: white;
      border-radius: 12px;
      padding: 40px;
      box-shadow: 0 4px 6px rgba(0,0,0,0.1);
      margin-bottom: 30px;
    }

    .product-image-section {
      display: flex;
      align-items: center;
      justify-content: center;
      background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
      border-radius: 12px;
      min-height: 400px;
      font-size: 120px;
    }

    .product-info-section h2 {
      font-size: 32px;
      margin-bottom: 15px;
      color: #333;
    }

    .product-price {
      font-size: 36px;
      font-weight: bold;
      color: #e74c3c;
      margin-bottom: 10px;
    }

    .product-price-original {
      font-size: 20px;
      text-decoration: line-through;
      color: #999;
      margin-left: 15px;
    }

    .discount-badge {
      display: inline-block;
      background: #28a745;
      color: white;
      padding: 8px 16px;
      border-radius: 25px;
      font-size: 14px;
      font-weight: bold;
      margin: 15px 0;
    }

    .product-specs {
      margin: 30px 0;
    }

    .product-specs h3 {
      font-size: 18px;
      margin-bottom: 15px;
      color: #333;
    }

    .product-specs ul {
      list-style: none;
    }

    .product-specs li {
      padding: 12px 0;
      border-bottom: 1px solid #eee;
      font-size: 15px;
      color: #666;
    }

    .product-specs li strong {
      color: #333;
      margin-right: 10px;
    }

    .purchase-btn {
      width: 100%;
      padding: 20px;
      background: #667eea;
      color: white;
      border: none;
      border-radius: 8px;
      font-size: 18px;
      font-weight: bold;
      cursor: pointer;
      transition: background 0.3s;
      margin-top: 20px;
    }

    .purchase-btn:hover {
      background: #5568d3;
    }

    .add-to-cart-btn {
      width: 100%;
      padding: 20px;
      background: white;
      color: #667eea;
      border: 2px solid #667eea;
      border-radius: 8px;
      font-size: 18px;
      font-weight: bold;
      cursor: pointer;
      transition: all 0.3s;
      margin-top: 15px;
    }

    .add-to-cart-btn:hover {
      background: #667eea;
      color: white;
    }

    .tracking-panel {
      background: white;
      border-radius: 12px;
      padding: 30px;
      box-shadow: 0 4px 6px rgba(0,0,0,0.1);
    }

    .tracking-panel h3 {
      font-size: 20px;
      margin-bottom: 20px;
      color: #333;
    }

    .tracking-panel p {
      font-size: 14px;
      color: #666;
      line-height: 1.8;
      margin-bottom: 15px;
    }

    code {
      background: #f5f5f5;
      padding: 3px 8px;
      border-radius: 4px;
      font-family: 'Courier New', monospace;
      font-size: 13px;
      color: #e74c3c;
    }

    @media (max-width: 768px) {
      .product-detail {
        grid-template-columns: 1fr;
      }
    }
  </style>
</head>
<body>
  <header>
    <h1>デモPC公式オンラインストア</h1>
  </header>

  <div class="container">
    <div class="product-detail"
         data-product-id="prod001"
         data-product-name="ノートPC A1 13インチモデル"
         data-product-price="169800"
         data-category="ノートPC">

      <div class="product-image-section">
        💻
      </div>

      <div class="product-info-section">
        <h2 id="productName">ノートPC A1 13インチモデル</h2>
        <div class="product-price">
          ¥<span id="productPrice">169,800</span>
          <span class="product-price-original">¥199,800</span>
        </div>
        <div class="discount-badge">🎉 期間限定 30%OFF</div>

        <div class="product-specs">
          <h3>製品仕様</h3>
          <ul>
            <li><strong>ディスプレイ:</strong> 13.4インチ OLED (3456×2160)</li>
            <li><strong>プロセッサー:</strong> Intel Core i7-1360P (最大5.0GHz)</li>
            <li><strong>メモリ:</strong> 16GB LPDDR5</li>
            <li><strong>ストレージ:</strong> 512GB NVMe SSD</li>
            <li><strong>グラフィックス:</strong> Intel Iris Xe</li>
            <li><strong>重量:</strong> 約1.24kg</li>
            <li><strong>バッテリー:</strong> 最大12時間</li>
            <li><strong>OS:</strong> Windows 11 Pro</li>
          </ul>
        </div>

        <button class="purchase-btn" onclick="purchase()">
          🛒 今すぐ購入する
        </button>

        <button class="add-to-cart-btn"
                data-add-to-cart
                data-product-id="prod001"
                data-product-name="Dell XPS 13 Plus"
                data-product-price="169800"
                onclick="addToCart()">
          📦 カートに追加
        </button>
      </div>
    </div>

    <div class="tracking-panel">
      <h3>🎯 このページで記録される行動データ</h3>
      <p>
        <strong>1. 商品閲覧イベント:</strong><br>
        このページを開いた時点で、商品ID <code id="displayProductId">prod001</code> の閲覧イベントが記録されます。
        これにより、ユーザーは「商品閲覧者」のオーディエンスセグメントに追加されます。
      </p>
      <p>
        <strong>2. カート追加イベント:</strong><br>
        「カートに追加」ボタンをクリックすると、カート追加イベントが記録されます。
        購入に至らなかった場合、「カート放棄者」セグメントに振り分けられ、リマーケティング広告の対象になります。
      </p>
      <p>
        <strong>3. ページ滞在時間・スクロール深度:</strong><br>
        ページ離脱時に、このページでの滞在時間とスクロール深度が自動的に記録されます。
        これらのデータは、ユーザーの興味関心度を測る指標として利用されます。
      </p>
      <p>
        <strong>広告クリックID (democlid):</strong> <code id="displayDemoclid">-</code><br>
        広告経由で訪問した場合、クリックIDが表示されます。購入時にこのIDと紐付けられ、
        どの広告からコンバージョンが発生したかを追跡できます。
      </p>
    </div>
  </div>

  <!-- デモ広告タグ(ベースタグ) -->
  <script src="../frontend/demo-tag.js"></script>
  <script>
    // タグ初期化
    dtag('config', 'DEMO-ADVERTISER-001');
  </script>

  <script>
    // URLパラメータから商品IDを取得
    const urlParams = new URLSearchParams(window.location.search);
    const productId = urlParams.get('id') || 'prod001';

    // 商品データベース
    const products = {
      'prod001': {
        name: 'ノートPC A1 13インチモデル',
        price: 169800,
        originalPrice: 199800,
        category: 'ノートPC',
        emoji: '💻'
      },
      'prod002': {
        name: 'ノートPC B1 15インチモデル',
        price: 89800,
        originalPrice: 119800,
        category: 'ノートPC',
        emoji: '🖥️'
      },
      'prod003': {
        name: 'ゲーミングPC C1',
        price: 159800,
        originalPrice: 189800,
        category: 'ゲーミングPC',
        emoji: ''
      }
    };

    // 商品情報を表示
    const product = products[productId];
    if (product) {
      document.getElementById('productName').textContent = product.name;
      document.getElementById('productPrice').textContent = product.price.toLocaleString();
      document.querySelector('.product-image-section').textContent = product.emoji;

      // data属性を更新
      const productCard = document.querySelector('.product-detail');
      productCard.dataset.productId = productId;
      productCard.dataset.productName = product.name;
      productCard.dataset.productPrice = product.price;
      productCard.dataset.category = product.category;
    }

    // トラッキング情報を表示
    document.addEventListener('DOMContentLoaded', () => {
      document.getElementById('displayProductId').textContent = productId;

      dtag('get', 'click_id', (clickId) => {
        if (clickId) {
          document.getElementById('displayDemoclid').textContent = clickId;
        }
      });
    });

    // カート追加(オーディエンスタグが自動で記録)
    function addToCart() {
      alert('カートに追加しました!\n\n「カート追加」イベントがバックエンドに送信され、オーディエンスセグメント「cart_abandoners」に自動的に追加されます。');
    }

    // 購入処理
    function purchase() {
      const product = products[productId];
      if (!product) return;

      // サンクスページに遷移
      window.location.href = `thanks.html?orderId=ORDER${Date.now()}&productId=${productId}&value=${product.price}`;
    }
  </script>
</body>
</html>
購入完了ページ
thanks.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>ご購入ありがとうございました</title>
  <style>
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
    }

    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
      background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
      min-height: 100vh;
      display: flex;
      align-items: center;
      justify-content: center;
      padding: 20px;
    }

    .container {
      max-width: 700px;
      width: 100%;
    }

    .thanks-card {
      background: white;
      border-radius: 20px;
      padding: 50px 40px;
      text-align: center;
      box-shadow: 0 20px 60px rgba(0,0,0,0.3);
    }

    .success-icon {
      font-size: 80px;
      margin-bottom: 20px;
      animation: bounce 1s ease;
    }

    @keyframes bounce {
      0%, 100% { transform: translateY(0); }
      50% { transform: translateY(-20px); }
    }

    h1 {
      font-size: 32px;
      color: #333;
      margin-bottom: 15px;
    }

    .subtitle {
      font-size: 18px;
      color: #666;
      margin-bottom: 30px;
    }

    .order-info {
      background: #f8f9fa;
      border-radius: 12px;
      padding: 25px;
      margin: 30px 0;
      text-align: left;
    }

    .order-info h3 {
      font-size: 18px;
      color: #333;
      margin-bottom: 20px;
      text-align: center;
    }

    .info-row {
      display: flex;
      justify-content: space-between;
      padding: 12px 0;
      border-bottom: 1px solid #e0e0e0;
      font-size: 15px;
    }

    .info-row:last-child {
      border-bottom: none;
    }

    .info-label {
      color: #666;
      font-weight: 500;
    }

    .info-value {
      color: #333;
      font-weight: bold;
    }

    .conversion-info {
      background: #e8f4fd;
      border-left: 4px solid #4285f4;
      border-radius: 8px;
      padding: 20px;
      margin: 30px 0;
      text-align: left;
    }

    .conversion-info h3 {
      font-size: 16px;
      color: #1967d2;
      margin-bottom: 15px;
    }

    .conversion-info p {
      font-size: 14px;
      color: #333;
      line-height: 1.8;
      margin-bottom: 10px;
    }

    code {
      background: #fff;
      padding: 3px 8px;
      border-radius: 4px;
      font-family: 'Courier New', monospace;
      font-size: 13px;
      color: #e74c3c;
      border: 1px solid #ddd;
    }

    .attribution-box {
      background: #fff9e6;
      border: 2px solid #ffc107;
      border-radius: 8px;
      padding: 20px;
      margin-top: 20px;
    }

    .attribution-box h4 {
      font-size: 15px;
      color: #856404;
      margin-bottom: 12px;
    }

    .attribution-box table {
      width: 100%;
      font-size: 13px;
    }

    .attribution-box td {
      padding: 8px 0;
      color: #333;
    }

    .attribution-box td:first-child {
      font-weight: bold;
      color: #856404;
      width: 40%;
    }

    .btn-home {
      display: inline-block;
      padding: 15px 40px;
      background: #667eea;
      color: white;
      text-decoration: none;
      border-radius: 8px;
      font-size: 16px;
      font-weight: bold;
      transition: background 0.3s;
      margin-top: 20px;
    }

    .btn-home:hover {
      background: #5568d3;
    }

    .debug-panel {
      background: #f5f5f5;
      border-radius: 8px;
      padding: 20px;
      margin-top: 30px;
      text-align: left;
      font-size: 13px;
      font-family: 'Courier New', monospace;
      color: #333;
      max-height: 300px;
      overflow-y: auto;
    }

    .debug-panel h4 {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
      font-size: 14px;
      margin-bottom: 10px;
      color: #666;
    }
  </style>
</head>
<body>
  <div class="container">
    <div class="thanks-card">
      <div class="success-icon">🎉</div>
      <h1>ご購入ありがとうございました!</h1>
      <p class="subtitle">ご注文を承りました。まもなく確認メールをお送りします。</p>

      <div class="order-info">
        <h3>📦 注文詳細</h3>
        <div class="info-row">
          <span class="info-label">注文番号</span>
          <span class="info-value" id="orderId">-</span>
        </div>
        <div class="info-row">
          <span class="info-label">商品名</span>
          <span class="info-value" id="productName">-</span>
        </div>
        <div class="info-row">
          <span class="info-label">購入金額</span>
          <span class="info-value" id="orderValue">-</span>
        </div>
        <div class="info-row">
          <span class="info-label">お届け予定</span>
          <span class="info-value">3〜5営業日</span>
        </div>
      </div>

      <div class="conversion-info">
        <h3>🎯 コンバージョントラッキングについて</h3>
        <p>
          このページが表示された時点で、<strong>コンバージョンイベント</strong>が自動的にバックエンドに送信されました。
        </p>
        <p>
          広告経由で訪問した場合、クリックID(DEMOCLID)と紐付けられ、どの広告からコンバージョンが発生したかを追跡できます。
        </p>

        <div class="attribution-box" id="attributionBox">
          <h4>📊 アトリビューション情報</h4>
          <table>
            <tr>
              <td>クリックID (democlid):</td>
              <td><code id="attrClickId">-</code></td>
            </tr>
            <tr>
              <td>キャンペーン:</td>
              <td id="attrCampaign">-</td>
            </tr>
            <tr>
              <td>広告グループ:</td>
              <td id="attrAdGroup">-</td>
            </tr>
            <tr>
              <td>検索キーワード:</td>
              <td id="attrKeyword">-</td>
            </tr>
            <tr>
              <td>流入経路:</td>
              <td id="attrSource">-</td>
            </tr>
          </table>
        </div>
      </div>

      <a href="search-ads.html" class="btn-home">🏠 検索ページに戻る</a>

      <div class="debug-panel">
        <h4>🔍 送信されたコンバージョンデータ(開発者向け)</h4>
        <pre id="debugData">読み込み中...</pre>
      </div>
    </div>
  </div>

  <!-- デモ広告タグ(ベースタグ) -->
  <script src="../frontend/demo-tag.js"></script>
  <script>
    // タグ初期化
    dtag('config', 'DEMO-ADVERTISER-001');
  </script>

  <script>
    // 商品データベース
    const products = {
      'prod001': { name: 'ノートPC A1 13インチモデル', price: 169800 },
      'prod002': { name: 'ノートPC B1 15インチモデル', price: 89800 },
      'prod003': { name: 'ゲーミングPC C1', price: 159800 }
    };

    // URLパラメータから注文情報を取得
    const urlParams = new URLSearchParams(window.location.search);
    const orderId = urlParams.get('orderId') || 'ORDER' + Date.now();
    const productId = urlParams.get('productId') || 'prod001';
    const orderValue = parseInt(urlParams.get('value')) || 169800;

    // 商品情報を表示
    const product = products[productId];
    document.getElementById('orderId').textContent = orderId;
    document.getElementById('productName').textContent = product ? product.name : 'ノートPC A1 13インチモデル';
    document.getElementById('orderValue').textContent = '¥' + orderValue.toLocaleString();

    // コンバージョンデータを準備
    const conversionData = {
      conversionType: 'purchase',
      value: orderValue,
      currency: 'JPY',
      orderId: orderId,
      productId: productId,
      productName: product ? product.name : 'ノートPC A1 13インチモデル',
      quantity: 1,
      metadata: {
        category: 'ノートPC',
        timestamp: new Date().toISOString()
      }
    };

    // ページ読み込み時にコンバージョンを送信
    document.addEventListener('DOMContentLoaded', () => {
      // コンバージョン送信(gtag.jsと同じ使い方)
      dtag('event', 'conversion', {
        value: conversionData.value,
        currency: conversionData.currency,
        transaction_id: conversionData.orderId,
        productId: conversionData.productId,
        productName: conversionData.productName,
        quantity: conversionData.quantity
      });

      // アトリビューション情報を表示
      displayAttributionInfo();

      // デバッグパネルにデータを表示
      dtag('get', 'user_id', (userId) => {
        dtag('get', 'click_id', (clickId) => {
          document.getElementById('debugData').textContent = JSON.stringify({
            eventType: 'conversion',
            userId: userId,
            clickId: clickId,
            ...conversionData
          }, null, 2);
        });
      });
    });

    // アトリビューション情報の表示
    function displayAttributionInfo() {
      dtag('get', 'click_id', (clickId) => {
        if (clickId) {
          // 広告経由の場合
          document.getElementById('attrClickId').textContent = clickId;

        // URLパラメータまたはCookieから取得した情報を表示
        const campaign = getCookie('campaign') || '春の新生活キャンペーン';
        const adGroup = getCookie('adgroup') || 'ノートPC_汎用';
        const keyword = getCookie('keyword') || 'ノートパソコン';

          document.getElementById('attrCampaign').textContent = campaign;
          document.getElementById('attrAdGroup').textContent = adGroup;
          document.getElementById('attrKeyword').textContent = keyword;
          document.getElementById('attrSource').textContent = '検索広告';
        } else {
          // 広告経由でない場合
          document.getElementById('attributionBox').innerHTML = `
            <h4>📊 アトリビューション情報</h4>
            <p style="color: #856404; font-size: 14px; margin: 10px 0;">
              広告経由での訪問ではありません(直接流入 or オーガニック検索)。<br>
              コンバージョンは記録されますが、広告クリックとの紐付けはありません。
            </p>
          `;
        }
      });
    }

    // Cookie取得関数
    function getCookie(name) {
      const value = `; ${document.cookie}`;
      const parts = value.split(`; ${name}=`);
      if (parts.length === 2) return parts.pop().split(';').shift();
      return null;
    }
  </script>
</body>
</html>

システム全体像

データフロー

実装のポイント

1. クリックIDの管理

Google広告のgclidと同じように、democlidというクリックIDを実装しました。

広告クリック時にIDを生成:

search-ads.html
function clickAd(ad, position) {
  // クリックID生成(タイムスタンプ + ランダム文字列)
  const clickId = generateClickId();

  // URLパラメータとして渡す
  const landingPageUrl = new URL('landing.html', window.location.href);
  landingPageUrl.searchParams.set('democlid', clickId);
  landingPageUrl.searchParams.set('campaign', ad.campaign);
  landingPageUrl.searchParams.set('keyword', searchKeyword);

  window.location.href = landingPageUrl.toString();
}

function generateClickId() {
  const timestamp = Date.now().toString(36);
  const random = Math.random().toString(36).substr(2, 9);
  return `EAIaI${timestamp}${random}`; // Google広告のGCLIDっぽい形式
}

ランディングページでCookieに保存:

demo-tag.js
function saveClickId() {
  const clickId = getUrlParameter('democlid');
  if (clickId) {
    // 90日間有効なCookieに保存(ラストクリック方式)
    setCookie('democlid', clickId, 90);

    // バックエンドに広告クリックを送信
    sendData({
      eventType: 'ad_click',
      clickId: clickId,
      userId: getUserId(),
      campaign: getUrlParameter('campaign'),
      keyword: getUrlParameter('keyword'),
      // ...
    });
  }
}

コンバージョン時に自動紐付け:

demo-tag.js
function sendConversion(eventData) {
  const clickId = getCookie('democlid'); // Cookieから取得

  sendData({
    eventType: 'conversion',
    userId: getUserId(),
    clickId: clickId || null, // クリックIDがあれば紐付け
    conversionValue: eventData.value,
    orderId: eventData.transaction_id,
    // ...
  });
}

これで、広告クリックから90日以内のコンバージョンは自動的に紐付けられます

2. ターゲティングロジックの実装

キーワードユーザーセグメントを組み合わせて、表示させる広告や順番を制御します。

実装コード:

search-ads.html
async function displayAds() {
  const searchKeyword = document.getElementById('searchInput').value.toLowerCase();
  // GASからユーザーセグメントを取得
  const userSegments = await getUserSegments(); // ['cart_abandoners', 'product_viewers']

  const matchedAds = adsDatabase.map(ad => {
    let relevanceScore = 0;

    // 1. キーワードマッチング
    const keywordMatch = ad.keywords.some(kw =>
      searchKeyword.includes(kw.toLowerCase())
    );
    if (keywordMatch) {
      relevanceScore += 10;
    }

    // 2. オーディエンスマッチング(リマーケティング)
    const audienceMatch = ad.audienceList.some(audience =>
      userSegments.includes(audience)
    );
    if (audienceMatch) {
      relevanceScore += 15; // オーディエンスマッチは高スコア
    }

    // 3. 広告ランク計算(CPC × 品質スコア × 関連性)
    const adRank = ad.cpc * ad.qualityScore * relevanceScore;

    return { ...ad, adRank, matched: relevanceScore > 0 };
  })
  .filter(ad => ad.matched)
  .sort((a, b) => b.adRank - a.adRank) // 広告ランク順
  .slice(0, 3); // 上位3件

  // 広告を表示
  displayMatchedAds(matchedAds);
}

ポイント:

  • キーワードマッチで基本スコア(10点)
  • オーディエンスマッチで追加スコア(15点)
  • 広告ランク = CPC × 品質スコア × 関連性スコア
  • リマーケティング広告は高スコアになりやすい

広告ランクの計算について
実際のGoogle広告では、広告ランクは以下のような要素で決まります:

  • 入札単価
  • 広告の品質(推定クリック率、広告の関連性、ランディングページの利便性)
  • 広告表示オプションなどの効果
  • ユーザーの検索状況(デバイス、場所、時間帯など)

本実装では簡易的に「CPC × 品質スコア × 関連性スコア」としています。

参考

3. オーディエンスセグメントの自動生成

ユーザーの行動を記録し、条件に応じて自動的にセグメントに振り分けます。

バックエンドでの判定ロジック:

code.gs
function determineAudienceSegments(data) {
  const segments = [];
  const userId = data.userId;

  // ユーザーの過去の行動を取得
  const userActions = getRecentUserActions(userId, 100);

  // 1. サイト訪問者(過去30日)
  const recentVisits = userActions.filter(row => {
    const daysSince = getDaysSince(row.timestamp);
    return daysSince <= 30;
  });
  if (recentVisits.length > 0) {
    segments.push('site_visitors_30d');
  }

  // 2. 商品閲覧者
  const productViews = userActions.filter(row =>
    row.action === 'product_view'
  );
  if (productViews.length > 0) {
    segments.push('product_viewers');
  }

  // 3. カート追加者(購入未完了)
  const cartAdds = userActions.filter(row =>
    row.action === 'add_to_cart'
  );
  if (cartAdds.length > 0) {
    segments.push('cart_abandoners');
  }

  // 4. 高頻度訪問者(7日で3回以上)
  const recent7Days = userActions.filter(row =>
    getDaysSince(row.timestamp) <= 7
  );
  if (recent7Days.length >= 3) {
    segments.push('frequent_visitors');
  }

  // 5. カテゴリ別興味関心
  const categories = userActions
    .filter(row => row.category)
    .map(row => row.category);

  const categoryCount = {};
  categories.forEach(cat => {
    categoryCount[cat] = (categoryCount[cat] || 0) + 1;
  });

  Object.keys(categoryCount).forEach(category => {
    if (categoryCount[category] >= 2) {
      segments.push(`interest_${category}`);
    }
  });

  // セグメントをUserSegmentsシートに保存
  updateUserSegments(userId, advertiserId, segments);

  return segments;
}

セグメント情報の保存と取得:

判定されたセグメントはUserSegmentsシートに保存され、検索広告ページから取得できるようになります:

code.gs
function updateUserSegments(userId, advertiserId, segments) {
  const sheet = getOrCreateSheet('UserSegments');
  // ユーザーIDで既存行を検索して更新、なければ新規追加
  const segmentString = segments.join(',');
  sheet.appendRow([userId, advertiserId, segmentString, new Date()]);
}

function doGet(e) {
  if (e.parameter.action === 'getUserSegments' && e.parameter.userId) {
    const segments = getUserSegments(e.parameter.userId);
    return ContentService.createTextOutput(
      JSON.stringify({ success: true, segments: segments })
    ).setMimeType(ContentService.MimeType.JSON);
  }
}
search-ads.html
async function getUserSegments() {
  const userId = getUserId();
  const url = `${CONFIG.gasEndpoint}?action=getUserSegments&userId=${userId}`;
  const response = await fetch(url);
  const data = await response.json();
  return data.segments || [];
}

重要なポイント:

  • ユーザーIDはCookieから直接読み取り、セグメント情報のみGASから取得します

フロントエンドでの自動トラッキング:

demo-tag.js
function trackProductView() {
  const productElement = document.querySelector('[data-product-id]');
  if (!productElement) return;

  sendData({
    eventType: 'audience',
    userId: getUserId(),
    action: 'product_view',
    productId: productElement.dataset.productId,
    productName: productElement.dataset.productName,
    category: productElement.dataset.category,
    // ...
  });
}

// カート追加の自動検知
document.addEventListener('click', (e) => {
  const target = e.target.closest('[data-add-to-cart]');
  if (target) {
    sendData({
      eventType: 'audience',
      action: 'add_to_cart',
      productId: target.dataset.productId,
      // ...
    });
  }
});

HTMLではdata属性を設定するだけで自動トラッキング:

<!-- 商品ページ -->
<div data-product-id="prod001"
     data-product-name="商品A"
     data-category="ノートPC">
</div>

<!-- カート追加ボタン -->
<button data-add-to-cart
        data-product-id="prod001">
  カートに追加
</button>

4. dtag関数

広告タグ埋め込み後、1つの関数で全ての操作を行う設計にしました。Google広告のgtag()のような使い勝手を意識したものです。

demo-tag.js
function dtag(command, ...args) {
  switch (command) {
    case 'config':
      // 初期化 - 自動トラッキング開始
      CONFIG.advertiserId = args[0];
      saveClickId();
      trackPageView();
      trackProductView();
      setupAddToCartTracking();
      break;

    case 'event':
      // イベント送信
      const eventName = args[0];
      const eventData = args[1] || {};

      if (eventName === 'conversion') {
        sendConversion(eventData);
      } else {
        sendCustomEvent(eventName, eventData);
      }
      break;

    case 'get':
      // データ取得
      const key = args[0];
      const callback = args[1];

      if (callback) {
        switch (key) {
          case 'user_id': callback(getUserId()); break;
          case 'click_id': callback(getClickId()); break;
          case 'session_id': callback(getSessionId()); break;
        }
      }
      break;
  }
}

window.dtag = dtag;

使い方:

<!-- 全ページ共通 -->
<script src="demo-tag.js"></script>
<script>
  dtag('config', 'DEMO-ADVERTISER-001');
</script>

<!-- コンバージョンページのみ追加 -->
<script>
  dtag('event', 'conversion', {
    value: 169800,
    currency: 'JPY',
    transaction_id: 'ORDER123'
  });
</script>

アトリビューションの仕組み

広告クリックとコンバージョンを紐付ける実装:

バックエンドでの処理:

code.gs
function recordConversion(data) {
  const sheet = getOrCreateSheet('Conversions');

  // クリックIDからクリックデータを取得
  const clickData = getClickDataByClickId(data.clickId);

  sheet.appendRow([
    new Date(),                    // タイムスタンプ
    data.userId,                   // ユーザーID
    data.clickId || '',            // クリックID
    data.conversionValue || 0,     // コンバージョン価値

    // アトリビューション情報(クリックデータから取得)
    clickData?.campaign || '',     // キャンペーン名
    clickData?.keyword || '',      // キーワード
    clickData?.cpc || 0,           // CPC

    // クリックからの経過時間を計算
    clickData ? calculateTimeDiff(clickData.timestamp, new Date()) : ''
  ]);

  return { attributed: !!clickData };
}

function getClickDataByClickId(clickId) {
  if (!clickId) return null;

  const sheet = SpreadsheetApp.getActiveSpreadsheet()
    .getSheetByName('AdClicks');
  const values = sheet.getDataRange().getValues();

  // クリックIDで検索(最新のものを取得)
  for (let i = values.length - 1; i >= 0; i--) {
    if (values[i][1] === clickId) { // B列: クリックID
      return {
        timestamp: values[i][0],
        campaign: values[i][3],
        keyword: values[i][5],
        cpc: values[i][8]
      };
    }
  }

  return null;
}

保存されるデータ

AdClicksシート

内容
タイムスタンプ クリック日時 2025/1/15 14:00
クリックID democlid ABC123XYZ
ユーザーID Cookie uid_abc123
広告主ID advertiserId DEMO-ADVERTISER-001
キャンペーン - 春の新生活キャンペーン
キーワード - ノートパソコン
CPC クリック単価 150
オーディエンスリスト 適用セグメント cart_abandoners

Audienceシート

内容
タイムスタンプ 行動日時 2025/1/15 14:02
ユーザーID Cookie uid_abc123
セッションID セッションごと sid_xyz789
広告主ID advertiserId DEMO-ADVERTISER-001
アクション 行動タイプ product_view / add_to_cart
商品ID - prod001
カテゴリ - ノートPC

Conversionsシート(アトリビューション付き)

内容
タイムスタンプ CV日時 2025/1/15 14:05
ユーザーID - uid_abc123
クリックID 紐付け ABC123XYZ
広告主ID advertiserId DEMO-ADVERTISER-001
コンバージョン価値 金額 169800
キャンペーン名 アトリビューション 春の新生活キャンペーン
キーワード アトリビューション ノートパソコン
クリックからの経過時間 - 0時間5分

UserSegmentsシート

内容
ユーザーID Cookie uid_abc123
広告主ID advertiserId DEMO-ADVERTISER-001
セグメント カンマ区切り cart_abandoners,product_viewers
更新日時 最終更新 2025/1/15 14:02

Cookieを使用したWeb広告技術についての補足

Cookie分類と広告配信の実態について、Claudeにまとめてもらった内容も合わせて記載いたします。

詳細は展開してご確認ください。

ファーストパーティCookieとサードパーティCookieの違い

今回の実装ではファーストパーティCookieを使用していますが、実際のWeb広告では長らくサードパーティCookieが主流でした。この2つの違いを理解することは、現代のWeb広告技術を理解する上で非常に重要です。

ファーストパーティCookie

訪問しているサイト自身が発行するCookieです。

例: example.com を訪問中
  ↓
example.com が発行するCookie
  ↓
example.com のドメインでのみ読み書き可能

特徴:

  • 訪問中のサイトのドメインと一致する
  • ブラウザの制限を受けにくい
  • ユーザーのプライバシー侵害リスクが比較的低い
  • Safari ITPやFirefox ETPでも制限されない(または制限が緩い)

今回の実装がファーストパーティである理由:
今回作成したdemo-tag.jsは、各サイトに直接設置され、そのサイトのドメインで動作します。例えば、shop.example.comに設置した場合、Cookieはshop.example.comドメインで発行されるため、ファーストパーティCookieとなります。

// shop.example.com 上で実行
setCookie('demo_uid', userId, 365); 
// → shop.example.com ドメインのCookieとして保存

サードパーティCookie

訪問しているサイトとは異なるドメインが発行するCookieです。

例: example.com を訪問中
  ↓
ad-network.com の広告タグが埋め込まれている
  ↓
ad-network.com が発行するCookie
  ↓
複数サイトを横断してユーザーを追跡可能

特徴:

  • 訪問中のサイトとは異なるドメインから発行される
  • 複数サイトを横断してユーザーを追跡できる(これが広告配信で重要)
  • プライバシー侵害の懸念から、ブラウザによる規制が強化されている
  • Safari(2017年〜)、Firefox(2019年〜)ですでにブロック
  • Chrome では完全廃止を見送り、ユーザー選択制へ方針転換(2024年7月発表)

「セカンドパーティCookie」が存在しない理由

技術的には「セカンドパーティCookie」という分類は存在しません。Cookieはブラウザの仕様上、「発行元ドメイン」と「現在のドメイン」が一致するか否かで分類されるため、中間的な状態は存在しないからです。

ただし、ビジネス用語として「セカンドパーティデータ」という言葉は存在します:

  • ファーストパーティデータ: 自社で収集したデータ
  • セカンドパーティデータ: 信頼できるパートナー企業から直接提供されるデータ(実質的には相手企業のファーストパーティデータ)
  • サードパーティデータ: データ提供会社が複数ソースから集約したデータ

実際の広告配信でサードパーティCookieが必要だった理由

問題: ファーストパーティCookieだけでは広告配信ができない

今回の実装では、shop.example.comに設置したタグは、そのサイト内でのユーザー行動しか追跡できません。しかし、実際の広告配信では以下が必要です:

1. クロスサイトトラッキング(リターゲティング広告)

ファーストパーティCookieの限界:

  • shop.example.comのCookieはnews.site.comでは読めない
  • 同じユーザーだと認識できない
  • リターゲティング広告が配信できない

サードパーティCookieの解決方法:

shop.example.com にアクセス
  ↓
ad-network.com のタグが ad-network.com ドメインでCookie発行
  (user_id: 12345)
  ↓
news.site.com にアクセス
  ↓
ad-network.com のタグが同じCookieを読み取り
  (user_id: 12345) ← 同じユーザーと判定!
  ↓
shop.example.com での行動履歴に基づいた広告を配信

2. コンバージョン計測とアトリビューション

問題のシナリオ:

  1. ユーザーがad-network.com経由で広告をクリック
  2. shop.example.comに遷移(広告ID: gclid=ABC123をURLパラメータで渡す)
  3. ユーザーがすぐに離脱
  4. 数日後、ユーザーが別のデバイスや別のブラウザでshop.example.comを直接訪問して購入

ファーストパーティCookieの限界:

  • デバイス・ブラウザが異なると同じユーザーと認識できない
  • 広告経由のコンバージョンとして計測できない

サードパーティCookieによる解決(従来の方法):

  • 広告ネットワークが独自のID(サードパーティCookie)で複数デバイスを紐付け
  • ログインベースのIDグラフで同一ユーザーを判定
  • クロスデバイストラッキングが可能

3. オーディエンス拡張(類似ユーザーターゲティング)

広告主A の顧客データ(ファーストパーティ)
  +
広告主B の顧客データ(ファーストパーティ)
  +
広告主C の顧客データ(ファーストパーティ)
  ↓
広告ネットワークが統合(サードパーティデータ化)
  ↓
「高額商品を購入する傾向のあるユーザー」セグメント作成
  ↓
類似ユーザーに広告配信

サードパーティCookieがあれば、広告ネットワークは複数の広告主のデータを統合し、より精緻なターゲティングが可能でした。

Chromeのサードパーティクッキー廃止方針の転換(2024年7月)

当初の計画と挫折

Googleは2020年に「2年以内にサードパーティCookieを段階的に廃止する」と発表し、代替技術としてPrivacy Sandboxを推進してきました。しかし、2024年7月、サードパーティCookieの完全廃止を断念し、大きく方針転換しました。

廃止を断念した主な理由:

  1. 広告業界からの強い反発

    • 既存の広告配信システムが機能しなくなる懸念
    • Privacy Sandboxの技術的成熟度への疑問
    • 計測精度の低下による広告主の離反リスク
  2. 英国競争・市場庁(CMA)からの指摘

    • Privacy SandboxによるGoogle優位性の懸念
    • 競争上の公平性に関する問題
    • 規制当局との合意形成の難航
  3. 技術的課題の未解決

    • Privacy Sandbox APIの採用が進まない
    • テスト結果が期待値に届かない
    • 移行コストと効果のバランス

新しい方針:「ユーザー選択制」への移行

2024年7月、Googleは以下の新方針を発表しました:

Chromeで新しいエクスペリエンスを導入し、サイト間でのブラウジング時におけるプライバシーを保護する選択について、ユーザーが情報に基づいた選択を行えるようにします。

出典: プライバシー サンドボックスの今後の計画について

具体的な変更内容:

重要なポイント:

  • サードパーティCookieは完全廃止されない
  • ユーザーが選択できる(デフォルト設定は未定)
  • Privacy Sandboxも引き続き開発・提供される
  • 広告主は両方の技術に対応する必要がある

現在のGoogle広告・Yahoo!広告の対応状況(2025年12月時点)

Google広告の対応

1. Enhanced Conversions(拡張コンバージョン)

  • ファーストパーティデータ(メールアドレス、電話番号等)をハッシュ化してGoogleに送信
  • Googleが自社のログインデータと照合してコンバージョンを計測
  • サードパーティCookie不要で計測精度を向上
// 拡張コンバージョンの実装例
gtag('set', 'user_data', {
  email: 'user@example.com',  // 自動的にハッシュ化される
  phone_number: '+81-90-1234-5678',
  address: { /* ... */ }
});

2. Privacy Sandbox の継続開発

  • 完全廃止は見送られたが、技術開発は継続
  • Topics API、Protected Audience API(旧FLEDGE)などは引き続き提供
  • サードパーティCookieをブロックしたユーザー向けの代替手段として位置づけ

主要なPrivacy Sandbox API:

API 用途 状態
Topics API 興味関心ターゲティング テスト実装中
Protected Audience API リマーケティング テスト実装中
Attribution Reporting API コンバージョン計測 テスト実装中
Private Aggregation API 集計レポート 開発中

3. Consent Mode(同意モード)v2

  • EU/EEA地域での法令遵守のために必須
  • ユーザーの同意状況に応じてタグの動作を制御
  • 同意なしでもモデリングによる推定コンバージョンを計測

4. Google Analytics 4(GA4)

  • 機械学習による行動モデリング
  • Cookie寿命の短縮に対応(Safariの7日間制限など)
  • サーバーサイド計測(Measurement Protocol)の強化
  • BigQueryとの統合による高度な分析

Yahoo!広告の対応

1. サイトジェネラルタグ(統合タグ)

  • ファーストパーティCookieベースの計測
  • サイト訪問者のデータを蓄積し、サイトリターゲティングに活用

2. コンバージョン測定の補完機能

  • 推定コンバージョン機能(統計的モデリング)
  • Cookie制限下でも一定の計測精度を維持

3. LINEとの連携強化

  • LINEのログインIDを活用したターゲティング
  • ファーストパーティデータの活用促進
  • LINE公式アカウントとの連携によるクロスデバイス計測

主要ブラウザの規制状況(2025年12月現在)

ブラウザ サードパーティCookie 対応状況
Safari ブロック(ITP) 2017年〜 完全ブロック
ファーストパーティCookieも7日で削除(トラッキング目的と判定された場合)
Firefox ブロック(ETP) 2019年〜 標準でブロック
Total Cookie Protectionで隔離
Chrome ユーザー選択制 2024年7月に方針転換
完全廃止を断念し、ユーザーが選択可能に
Privacy Sandboxは代替手段として継続開発
Edge Chromeに準拠 Chromiumベースのため、Chromeと同様の対応
Brave 完全ブロック 最も厳格なプライバシー保護

今後の広告配信の方向性

サードパーティCookieの完全廃止は見送られましたが、プライバシー保護の流れは不可逆的です。広告配信は以下の複数の技術を組み合わせたハイブリッドなアプローチに移行しています:

1. ファーストパーティデータの活用強化(最重要)

自社サイトでの行動データ
  +
会員情報・購買履歴
  +
拡張コンバージョン(ハッシュ化データ)
  ↓
CDP(Customer Data Platform)で統合管理
  ↓
精緻なターゲティングと効果測定

主要なトレンド:

  • CDP導入の加速
  • ゼロパーティデータ(ユーザーが明示的に提供するデータ)の重視
  • CRM連携の強化

2. コンテキストターゲティングの再評価

ページコンテンツ
  +
キーワード
  +
カテゴリ
  ↓
ユーザー追跡なしで広告配信
  ↓
プライバシー保護とのバランス

技術の進化:

  • AI/機械学習によるコンテンツ理解の高度化
  • 画像・動画コンテンツの解析
  • リアルタイムな文脈理解

3. Privacy Sandbox等の新技術(補完的役割)

Chromeでサードパーティcookieをブロックしたユーザー向け:

  • Topics API: ブラウザベースの興味関心判定
  • Protected Audience API: プライバシー保護型リマーケティング
  • Attribution Reporting API: 差分プライバシーを用いた計測

位置づけの変化:

  • 当初: サードパーティCookieの「完全代替」
  • 現在: Safari/Firefoxユーザーや、Chromeでブロックしたユーザー向けの「補完技術」

4. サーバーサイドトラッキングの普及

メリット:

  • ブラウザのCookie制限を回避
  • データの正確性向上
  • ページ読み込み速度の改善

実装例:

  • Google Tag Manager Server-side
  • Meta Conversions API
  • TikTok Events API

5. クリーンルーム技術

広告主のデータ
  +
プラットフォームのデータ
  ↓
暗号化された環境で分析
(個人情報は見えない)
  ↓
集計レベルの洞察のみ取得

提供事例:

  • Google Ads Data Hub
  • Amazon Marketing Cloud
  • Snowflake Data Clean Rooms

6. ID統合ソリューション(限定的)

メールアドレスベースのID:

  • The Trade Desk の Unified ID 2.0(UID2)
  • LiveRamp の RampID
  • ID5

課題:

  • EU GDPRとの整合性
  • ユーザーの明示的な同意が必要
  • Safari/Firefoxでは動作しない
  • Chromeでも今後制限される可能性

今回の実装との関連

今回作成したシステムは、ファーストパーティCookieベースの広告計測システムです。これは現在の広告業界で最も重視されているアプローチであり、以下の実装と本質的に同じです:

  • Google Analytics 4 の基本計測方式
  • Yahoo!サイトジェネラルタグの基本構造
  • 各種広告タグの「自社サイト内での行動追跡」部分

今回の実装が現実的な理由:

  1. ブラウザ制限の影響を受けにくい

    • Safari/Firefoxでも動作する
    • Chromeでサードパーティcookieをブロックしても動作する
  2. GDPR/CCPAなどの法規制にも対応しやすい

    • ユーザーは訪問しているサイトを明確に認識している
    • 同意取得のプロセスが明確
  3. 将来性がある

    • サードパーティCookie廃止の有無に関わらず有効
    • 業界全体がこの方向にシフトしている

ただし、実際の広告配信プラットフォームでは、これに加えて:

  • サーバーサイドでの統合・名寄せ
  • 機械学習による行動予測とモデリング
  • Privacy Sandboxなどの補完技術との統合
  • 複数デバイス・ブラウザでの統合計測
  • 法令遵守のための同意管理(Consent Management Platform)

などが実装されており、より複雑なシステムになっています。

まとめ:2025年のWeb広告の現実

サードパーティCookieの現状:

  • ✅ Safari/Firefoxでは完全ブロック(2017年〜)
  • ✅ Chromeでは完全廃止を断念、ユーザー選択制へ(2024年7月〜)
  • ❌ 業界が当初期待した「段階的かつ完全な廃止」は実現せず

実務での対応:

  • ファーストパーティデータが最優先(CDP、会員データ、拡張コンバージョン)
  • サーバーサイドトラッキングの導入
  • コンテキストターゲティングの見直し
  • Privacy Sandboxは「補完技術」として理解
  • サードパーティCookieは当面は使えるが、依存すべきではない

Googleの方針転換により、業界には一時的な「安堵」が広がりましたが、プライバシー保護の潮流は止まりません。広告主・マーケターは、サードパーティCookieに依存しない測定・配信の仕組みを構築することが引き続き重要です。

まとめ

GAS/JavaScriptを使用して検索広告の仕組みをAIを使いながら学んでみました。

普段GTMで設定している広告タグも、裏ではこのような処理が行われているのだと感じ、理解が深まりました。
今度は他の広告形式(SNSやディスプレイなど)や、GTMのようなタグマネージャーの仕組みも簡易的に実装して学習してみたいと思います。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?