知识库首页 广告 02_hourly-lite_google-ads-script.txt

02 hourly lite google ads script

本地来源:广告/ads-scripts/02_hourly-lite_google-ads-script.txt

// IMPORTANT: Paste this file as-is into ONE script project.
// Do NOT wrap it with `function main() { ... }` and do NOT mix 01/02/03 in the same project.

const CONFIG = {
  SPREADSHEET_URL:
    'https://docs.google.com/spreadsheets/d/1ak5fsF8J4kZYh_IQdTrMI0Goeus9ToV6j9kd8xJ_suI/edit',
  MAX_RUNTIME_MINUTES: 20,
  STATUS_SHEET: '00_STATUS_LITE',
  LOG_SHEET: '00_RUN_LOG_LITE',
  SUMMARY_SHEET: '90_LITE_SUMMARY',
  ENABLE_FEISHU: true,
  FEISHU_WEBHOOK_URL: '',
  FEISHU_TOKEN: '',
};

function main() {
  const ss = SpreadsheetApp.openByUrl(CONFIG.SPREADSHEET_URL);
  const account = AdsApp.currentAccount();
  const tz = account.getTimeZone();
  const startedAt = new Date();
  const runId = Utilities.formatDate(startedAt, tz, 'yyyyMMdd-HHmmss');

  ensureLiteLogHeader(ss);
  writeLiteStatus(ss, runId, account, tz, 'RUNNING', '');

  const jobs = buildLiteJobs();
  for (let i = 0; i < jobs.length; i++) {
    if (elapsedMinutes(startedAt) >= CONFIG.MAX_RUNTIME_MINUTES) {
      appendLiteLog(ss, [
        runId,
        formatDate(new Date(), tz),
        String(account.getCustomerId()),
        safeString(account.getName()),
        'TIMEOUT_GUARD',
        jobs[i].tab,
        0,
        0,
        'hourly-lite hit timeout guard',
      ]);
      writeLiteStatus(ss, runId, account, tz, 'PAUSED_TIMEOUT', jobs[i].tab);
      return;
    }

    runLiteExportJob(ss, account, tz, runId, jobs[i]);
  }

  let summary = null;
  try {
    summary = buildLiteSummary(ss, runId, account, tz);
  } catch (e) {
    appendLiteLog(ss, [
      runId,
      formatDate(new Date(), tz),
      String(account.getCustomerId()),
      safeString(account.getName()),
      'ERROR',
      CONFIG.SUMMARY_SHEET,
      0,
      0,
      truncate(String(e), 1000),
    ]);
  }

  writeLiteStatus(ss, runId, account, tz, 'FINISHED', '');

  if (CONFIG.ENABLE_FEISHU && summary) {
    try {
      sendFeishuText(buildLiteFeishuText(summary));
    } catch (e) {
      appendLiteLog(ss, [
        runId,
        formatDate(new Date(), tz),
        String(account.getCustomerId()),
        safeString(account.getName()),
        'ERROR',
        'FEISHU',
        0,
        0,
        truncate(String(e), 1000),
      ]);
    }
  }
}

function buildLiteJobs() {
  return [
    {
      tab: 'L01_customer',
      minCols: 5,
      query: `
        SELECT
          customer.id,
          customer.descriptive_name,
          customer.currency_code,
          customer.time_zone,
          customer.status
        FROM customer
      `,
    },
    {
      tab: 'L10_campaign_today',
      minCols: 9,
      query: `
        SELECT
          campaign.id,
          campaign.name,
          campaign.status,
          metrics.impressions,
          metrics.clicks,
          metrics.ctr,
          metrics.average_cpc,
          metrics.cost_micros,
          metrics.conversions
        FROM campaign
        WHERE campaign.status != REMOVED
          AND campaign.advertising_channel_type = SEARCH
          AND segments.date DURING TODAY
      `,
    },
    {
      tab: 'L11_campaign_yesterday',
      minCols: 9,
      query: `
        SELECT
          campaign.id,
          campaign.name,
          campaign.status,
          metrics.impressions,
          metrics.clicks,
          metrics.ctr,
          metrics.average_cpc,
          metrics.cost_micros,
          metrics.conversions
        FROM campaign
        WHERE campaign.status != REMOVED
          AND campaign.advertising_channel_type = SEARCH
          AND segments.date DURING YESTERDAY
      `,
    },
    {
      tab: 'L12_campaign_7d_nonzero',
      minCols: 9,
      query: `
        SELECT
          campaign.id,
          campaign.name,
          campaign.status,
          metrics.impressions,
          metrics.clicks,
          metrics.ctr,
          metrics.average_cpc,
          metrics.cost_micros,
          metrics.conversions
        FROM campaign
        WHERE campaign.status != REMOVED
          AND campaign.advertising_channel_type = SEARCH
          AND segments.date DURING LAST_7_DAYS
          AND (
            metrics.impressions > 0
            OR metrics.clicks > 0
            OR metrics.cost_micros > 0
          )
      `,
    },
    {
      tab: 'L13_ad_policy_status',
      minCols: 8,
      query: `
        SELECT
          campaign.id,
          campaign.name,
          ad_group.id,
          ad_group.name,
          ad_group_ad.ad.id,
          ad_group_ad.policy_summary.approval_status,
          ad_group_ad.policy_summary.review_status,
          ad_group_ad.status
        FROM ad_group_ad
        WHERE ad_group_ad.status != REMOVED
      `,
    },
    {
      tab: 'L20_keyword_country_today',
      minCols: 11,
      query: `
        SELECT
          campaign.id,
          campaign.name,
          ad_group.id,
          ad_group.name,
          ad_group_criterion.criterion_id,
          ad_group_criterion.keyword.text,
          ad_group_criterion.keyword.match_type,
          ad_group_criterion.final_urls,
          metrics.clicks,
          metrics.average_cpc,
          metrics.cost_micros
        FROM keyword_view
        WHERE campaign.status != REMOVED
          AND ad_group_criterion.status != REMOVED
          AND segments.date DURING TODAY
          AND metrics.clicks > 0
      `,
    },
  ];
}

function runLiteExportJob(ss, account, tz, runId, job) {
  const t0 = new Date();
  const sh = prepareLiteSheet(ss, job.tab, job.minCols || 8);
  try {
    AdsApp.report(job.query).exportToSheet(sh);
    const rows = Math.max(sh.getLastRow() - 1, 0);
    trimLiteSheet(sh, job.minCols || 8);

    appendLiteLog(ss, [
      runId,
      formatDate(new Date(), tz),
      String(account.getCustomerId()),
      safeString(account.getName()),
      'OK',
      job.tab,
      rows,
      secondsBetween(t0, new Date()),
      '',
    ]);
  } catch (e) {
    const fallbackQuery = getLiteFallbackQuery(job, e);
    if (fallbackQuery) {
      try {
        AdsApp.report(fallbackQuery).exportToSheet(sh);
        const fallbackRows = Math.max(sh.getLastRow() - 1, 0);
        trimLiteSheet(sh, job.minCols || 8);
        appendLiteLog(ss, [
          runId,
          formatDate(new Date(), tz),
          String(account.getCustomerId()),
          safeString(account.getName()),
          'OK_FALLBACK',
          job.tab,
          fallbackRows,
          secondsBetween(t0, new Date()),
          truncate(`fallback after error: ${String(e)}`, 1000),
        ]);
        return;
      } catch (fallbackErr) {
        appendLiteLog(ss, [
          runId,
          formatDate(new Date(), tz),
          String(account.getCustomerId()),
          safeString(account.getName()),
          'ERROR',
          job.tab,
          0,
          secondsBetween(t0, new Date()),
          truncate(`primary=${String(e)} | fallback=${String(fallbackErr)}`, 1000),
        ]);
        return;
      }
    }

    appendLiteLog(ss, [
      runId,
      formatDate(new Date(), tz),
      String(account.getCustomerId()),
      safeString(account.getName()),
      'ERROR',
      job.tab,
      0,
      secondsBetween(t0, new Date()),
      truncate(String(e), 1000),
    ]);
  }
}

function getLiteFallbackQuery(job, err) {
  if (!job || job.tab !== 'L12_campaign_7d_nonzero') return '';
  const msg = safeString(err).toUpperCase();
  if (
    msg.indexOf('BAD_FIELD_NAME') < 0 &&
    msg.indexOf('UNRECOGNIZED') < 0 &&
    msg.indexOf('INVALID') < 0
  ) {
    return '';
  }
  return `
    SELECT
      campaign.id,
      campaign.name,
      campaign.status,
      metrics.impressions,
      metrics.clicks,
      metrics.ctr,
      metrics.average_cpc,
      metrics.cost_micros,
      metrics.conversions
    FROM campaign
    WHERE campaign.status != REMOVED
      AND campaign.advertising_channel_type = SEARCH
      AND segments.date DURING LAST_7_DAYS
  `;
}

function buildLiteSummary(ss, runId, account, tz) {
  const out = prepareLiteSheet(ss, CONFIG.SUMMARY_SHEET, 3);
  out.getRange(1, 1, 1, 3).setValues([['metric', 'value', 'note']]);

  const today = mustGetSheet(ss, 'L10_campaign_today');
  const tVals = today.getDataRange().getValues();
  const th = tVals[0];
  const iTImpr = findCol(th, 'metrics.impressions');
  const iTClk = findCol(th, 'metrics.clicks');
  const iTCost = findCol(th, 'metrics.cost_micros');
  const iTConv = findCol(th, 'metrics.conversions');

  let todayCampaigns = 0;
  let todayNonzeroCampaigns = 0;
  let todayImpr = 0;
  let todayClk = 0;
  let todayCost = 0;
  let todayConv = 0;

  for (let i = 1; i < tVals.length; i++) {
    const row = tVals[i];
    const impr = toNumber(row[iTImpr]);
    const clk = toNumber(row[iTClk]);
    const cost = toNumber(row[iTCost]);
    const conv = toNumber(row[iTConv]);

    todayCampaigns++;
    if (impr > 0 || clk > 0 || cost > 0) todayNonzeroCampaigns++;
    todayImpr += impr;
    todayClk += clk;
    todayCost += cost;
    todayConv += conv;
  }

  const policy = mustGetSheet(ss, 'L13_ad_policy_status');
  const pVals = policy.getDataRange().getValues();
  const ph = pVals[0];
  const iAppr = findCol(ph, 'ad_group_ad.policy_summary.approval_status');
  const iCamp = findCol(ph, 'campaign.id');
  const iAdg = findCol(ph, 'ad_group.id');

  let adCount = 0;
  let disapproved = 0;
  let approvedLimited = 0;
  const campaignsWithAds = new Set();
  const adgroupsWithAds = new Set();

  for (let i = 1; i < pVals.length; i++) {
    const row = pVals[i];
    const approval = safeString(row[iAppr]);
    const cId = safeString(row[iCamp]);
    const aId = safeString(row[iAdg]);
    adCount++;
    if (approval === 'DISAPPROVED') disapproved++;
    if (approval === 'APPROVED_LIMITED') approvedLimited++;
    if (cId) campaignsWithAds.add(cId);
    if (aId) adgroupsWithAds.add(aId);
  }

  const ctr = todayImpr > 0 ? todayClk / todayImpr : 0;
  const avgCpc = todayClk > 0 ? (todayCost / 1000000) / todayClk : 0;

  out.getRange(2, 1, 16, 3).setValues([
    ['run_id', runId, 'unique run id'],
    ['account_id', String(account.getCustomerId()), 'google ads customer id'],
    ['account_timezone', account.getTimeZone(), 'account timezone'],
    ['exported_at', formatDate(new Date(), tz), 'snapshot time'],
    ['today_campaigns', todayCampaigns, 'campaigns in today table'],
    ['today_nonzero_campaigns', todayNonzeroCampaigns, 'campaigns with traffic today'],
    ['today_impressions', Math.round(todayImpr), 'sum metrics.impressions'],
    ['today_clicks', Math.round(todayClk), 'sum metrics.clicks'],
    ['today_ctr', ctr.toFixed(4), 'clicks/impressions'],
    ['today_cost', (todayCost / 1000000).toFixed(2), 'account currency'],
    ['today_avg_cpc', avgCpc.toFixed(4), 'cost/clicks'],
    ['today_conversions', todayConv.toFixed(2), 'sum conversions'],
    ['ads_total', adCount, 'ads counted in L13_ad_policy_status'],
    ['disapproved_ads', disapproved, 'approval_status=DISAPPROVED'],
    ['approved_limited_ads', approvedLimited, 'approval_status=APPROVED_LIMITED'],
    ['campaigns_with_ads', campaignsWithAds.size, 'unique campaign.id in L13'],
  ]);

  out.appendRow(['adgroups_with_ads', adgroupsWithAds.size, 'unique ad_group.id in L13']);
  out.appendRow(['policy_issue_rate', adCount > 0 ? ((disapproved + approvedLimited) / adCount).toFixed(4) : '0', '(disapproved+approved_limited)/ads_total']);

  trimLiteSheet(out, 3);

  return {
    runId: runId,
    accountId: String(account.getCustomerId()),
    exportedAt: formatDate(new Date(), tz),
    todayImpr: Math.round(todayImpr),
    todayClk: Math.round(todayClk),
    todayCost: (todayCost / 1000000).toFixed(2),
    todayConv: todayConv.toFixed(2),
    disapproved: disapproved,
    approvedLimited: approvedLimited,
    policyIssueRate: adCount > 0 ? ((disapproved + approvedLimited) / adCount).toFixed(4) : '0',
  };
}

function buildLiteFeishuText(summary) {
  return [
    '[Google Ads Hourly Lite]',
    `run_id: ${summary.runId}`,
    `account: ${summary.accountId}`,
    `exported_at: ${summary.exportedAt}`,
    `today: impr=${summary.todayImpr}, clicks=${summary.todayClk}, cost=${summary.todayCost}, conv=${summary.todayConv}`,
    `policy: disapproved=${summary.disapproved}, approved_limited=${summary.approvedLimited}, issue_rate=${summary.policyIssueRate}`,
  ].join('\n');
}

function sendFeishuText(text) {
  const prop = PropertiesService.getScriptProperties();
  const webhookRaw =
    prop.getProperty('RAC_FEISHU_WEBHOOK_URL') ||
    CONFIG.RAC_FEISHU_WEBHOOK_URL ||
    CONFIG.FEISHU_WEBHOOK_URL;
  const tokenRaw =
    prop.getProperty('RAC_FEISHU_TOKEN') ||
    CONFIG.RAC_FEISHU_TOKEN ||
    CONFIG.FEISHU_TOKEN;
  const webhook = safeString(webhookRaw).trim();
  const token = safeString(tokenRaw).trim();
  if (!webhook) return;
  const isFeishuWebhook = /open\.feishu\.cn\/open-apis\/bot\/v2\/hook\//.test(webhook);

  const payload = {
    msg_type: 'text',
    content: { text: text },
  };

  const headers = { 'Content-Type': 'application/json' };
  let signedTimestamp = '';
  if (token && isFeishuWebhook) {
    // Feishu custom bot sign:
    // string_to_sign = timestamp + "\\n" + secret
    // sign = Base64(HMAC_SHA256("", string_to_sign))
    signedTimestamp = String(Math.floor(new Date().getTime() / 1000));
    const stringToSign = signedTimestamp + '\n' + token;
    const signBytes = Utilities.computeHmacSha256Signature(
      '',
      stringToSign,
      Utilities.Charset.UTF_8
    );
    payload.timestamp = signedTimestamp;
    payload.sign = Utilities.base64Encode(signBytes);
  } else if (token) {
    headers.Authorization = 'Bearer ' + token;
  }

  const resp = UrlFetchApp.fetch(webhook, {
    method: 'post',
    headers: headers,
    payload: JSON.stringify(payload),
    muteHttpExceptions: true,
  });
  const code = resp.getResponseCode();
  const body = resp.getContentText() || '';
  const allHeaders = resp.getAllHeaders ? resp.getAllHeaders() : {};
  const serverDate =
    (allHeaders && (allHeaders.Date || allHeaders.date)) ? String(allHeaders.Date || allHeaders.date) : '';

  let appCode = null;
  let appMsg = '';
  if (body) {
    try {
      const parsed = JSON.parse(body);
      if (parsed && typeof parsed.code !== 'undefined') {
        appCode = Number(parsed.code);
        appMsg = String(parsed.msg || parsed.message || '');
      } else if (parsed && typeof parsed.StatusCode !== 'undefined') {
        appCode = Number(parsed.StatusCode);
        appMsg = String(parsed.StatusMessage || '');
      }
    } catch (_e) {}
  }

  const okHttp = code >= 200 && code < 300;
  const okApp = appCode === null || appCode === 0;
  if (!okHttp || !okApp) {
    Logger.log(
      'Feishu send failed. code=' +
        code +
        ', appCode=' +
        String(appCode) +
        ', appMsg=' +
        appMsg +
        ', signedTs=' +
        signedTimestamp +
        ', tokenLen=' +
        String(token ? token.length : 0) +
        ', serverDate=' +
        serverDate +
        ', body=' +
        body
    );
  }
}

function writeLiteStatus(ss, runId, account, tz, status, note) {
  const sh = prepareLiteSheet(ss, CONFIG.STATUS_SHEET, 2);
  sh.getRange(1, 1, 8, 2).setValues([
    ['run_id', runId],
    ['account_id', String(account.getCustomerId())],
    ['account_timezone', account.getTimeZone()],
    ['status', status],
    ['note', note],
    ['updated_at', formatDate(new Date(), tz)],
    ['is_realtime_stream', 'NO'],
    ['type', 'hourly-lite'],
  ]);
  trimLiteSheet(sh, 2);
}

function ensureLiteLogHeader(ss) {
  const sh = getOrCreateSheet(ss, CONFIG.LOG_SHEET);
  if (sh.getLastRow() === 0) {
    sh.getRange(1, 1, 1, 9).setValues([[
      'run_id',
      'timestamp',
      'account_id',
      'account_name',
      'status',
      'tab',
      'row_count',
      'duration_sec',
      'message',
    ]]);
  }
}

function appendLiteLog(ss, row) {
  getOrCreateSheet(ss, CONFIG.LOG_SHEET).appendRow(row);
}

function mustGetSheet(ss, name) {
  const sh = ss.getSheetByName(name);
  if (!sh || sh.getLastRow() < 2) {
    throw new Error('missing or empty sheet: ' + name);
  }
  return sh;
}

function prepareLiteSheet(ss, name, minCols) {
  const sh = getOrCreateSheet(ss, name);
  sh.clear();
  resizeLiteSheet(sh, 60, Math.max(8, minCols || 8));
  return sh;
}

function trimLiteSheet(sh, minCols) {
  const targetRows = Math.max(sh.getLastRow() + 5, 20);
  const targetCols = Math.max(sh.getLastColumn() + 1, minCols || 1);
  resizeLiteSheet(sh, targetRows, targetCols);
}

function resizeLiteSheet(sh, targetRows, targetCols) {
  const tr = Math.max(1, targetRows);
  const tc = Math.max(1, targetCols);
  const maxRows = sh.getMaxRows();
  const maxCols = sh.getMaxColumns();

  if (maxRows > tr) sh.deleteRows(tr + 1, maxRows - tr);
  else if (maxRows < tr) sh.insertRowsAfter(maxRows, tr - maxRows);

  if (maxCols > tc) sh.deleteColumns(tc + 1, maxCols - tc);
  else if (maxCols < tc) sh.insertColumnsAfter(maxCols, tc - maxCols);
}

function getOrCreateSheet(ss, name) {
  let sh = ss.getSheetByName(name);
  if (!sh) sh = ss.insertSheet(name);
  return sh;
}

function elapsedMinutes(startDate) {
  return (new Date().getTime() - startDate.getTime()) / 60000;
}

function secondsBetween(a, b) {
  return Math.round((b.getTime() - a.getTime()) / 1000);
}

function formatDate(d, tz) {
  return Utilities.formatDate(d, tz, 'yyyy-MM-dd HH:mm:ss');
}

function truncate(text, maxLen) {
  const s = String(text || '');
  return s.length <= maxLen ? s : s.substring(0, maxLen - 3) + '...';
}

function safeString(v) {
  if (v === null || v === undefined) return '';
  return String(v);
}

function toNumber(v) {
  if (v === null || v === undefined || v === '') return 0;
  const n = Number(v);
  return isNaN(n) ? 0 : n;
}

function findCol(headers, name) {
  const idx = headers.indexOf(name);
  if (idx < 0) throw new Error('missing column: ' + name);
  return idx;
}

本文档为站内渲染。原始文件本地路径:saas/source/ads/广告-ads-scripts-02_hourly-lite_google-ads-script-43b540.txt(仅本地保留,不入库不部署)