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

02 hourly lite google ads script

本地来源:广告/ads-data-script/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',
  HOURLY_TIMELINE_SHEET: '91_HOURLY_TIMELINE',
  TODAY_CAMPAIGN_SHEET: 'L10_campaign_today',
  TODAY_DETAIL_SHEET: 'L20_keyword_country_today',
  ENABLE_FEISHU: true,
  SEND_FALLBACK_ALERT_WHEN_SUMMARY_MISSING: true,
  SHOW_POLICY_LINE_WHEN_ZERO: false,
  INCLUDE_HISTORY_LINE: true,
  INCLUDE_HOURLY_TIMELINE_LINE: true,
  INCLUDE_MONITOR_CUMULATIVE_LINE: true,
  HOURLY_TIMELINE_RECENT_HOURS: 10,
  HOURLY_TIMELINE_KEEP_ROWS: 3000,
  RAC_FEISHU_WEBHOOK_URL: 'https://open.feishu.cn/open-apis/bot/v2/hook/2c4db3a0-7bdc-42a6-bc0e-44da7344fe87',
  RAC_FEISHU_TOKEN: 'oRbJgofj3viHwkzjflTozh',
  FEISHU_WEBHOOK_URL: 'https://open.feishu.cn/open-apis/bot/v2/hook/2c4db3a0-7bdc-42a6-bc0e-44da7344fe87',
  FEISHU_TOKEN: 'oRbJgofj3viHwkzjflTozh',
};

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');
  const prevHourWindow = getPreviousHourWindow(tz);

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

  const jobs = buildLiteJobs(prevHourWindow);
  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;
  let timelineContext = null;
  try {
    summary = buildLiteSummary(ss, runId, account, tz, prevHourWindow);
  } 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),
    ]);
  }

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

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

  if (CONFIG.ENABLE_FEISHU && (summary || CONFIG.SEND_FALLBACK_ALERT_WHEN_SUMMARY_MISSING)) {
    try {
      const text = summary
        ? buildLiteFeishuText(summary, timelineContext)
        : buildLiteFallbackFeishuText(runId, account, tz, 'SUMMARY_MISSING_OR_BUILD_FAILED');
      const sendResult = sendFeishuText(text);
      appendLiteLog(ss, [
        runId,
        formatDate(new Date(), tz),
        String(account.getCustomerId()),
        safeString(account.getName()),
        sendResult && sendResult.sent ? 'OK' : 'WARN',
        summary ? 'FEISHU' : 'FEISHU_FALLBACK',
        0,
        0,
        sendResult && sendResult.sent
          ? `sent code=${String(sendResult.code)} source=${safeString(sendResult.source)}`
          : `not sent reason=${safeString(sendResult && sendResult.reason)} code=${String(sendResult && sendResult.code)} source=${safeString(sendResult && sendResult.source)} msg=${safeString(sendResult && sendResult.appMsg)}`,
      ]);
    } catch (e) {
      appendLiteLog(ss, [
        runId,
        formatDate(new Date(), tz),
        String(account.getCustomerId()),
        safeString(account.getName()),
        'ERROR',
        'FEISHU',
        0,
        0,
        truncate(String(e), 1000),
      ]);
    }
  }
}

function getPreviousHourWindow(tz) {
  const now = new Date();
  const currentHourStart = new Date(now.getTime());
  currentHourStart.setMinutes(0, 0, 0);
  const prevHourStart = new Date(currentHourStart.getTime() - 60 * 60 * 1000);
  const prevHourEnd = currentHourStart;
  const date = Utilities.formatDate(prevHourStart, tz, 'yyyy-MM-dd');
  const hour = Number(Utilities.formatDate(prevHourStart, tz, 'H'));
  const startText = Utilities.formatDate(prevHourStart, tz, 'yyyy-MM-dd HH:00');
  const endText = Utilities.formatDate(prevHourEnd, tz, 'yyyy-MM-dd HH:00');
  return {
    date: date,
    hour: hour,
    startText: startText,
    endText: endText,
    label: `${startText} ~ ${endText}`,
  };
}

function buildLiteJobs(prevHourWindow) {
  const hourDate = safeString(prevHourWindow && prevHourWindow.date);
  const hourNum = Number(prevHourWindow && prevHourWindow.hour);
  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_prev_hour',
      minCols: 14,
      query: `
        SELECT
          campaign.id,
          campaign.name,
          campaign.status,
          metrics.impressions,
          metrics.clicks,
          metrics.ctr,
          metrics.average_cpc,
          metrics.cost_micros,
          metrics.conversions,
          metrics.search_impression_share,
          metrics.search_rank_lost_impression_share,
          metrics.search_budget_lost_impression_share,
          segments.date,
          segments.hour
        FROM campaign
        WHERE campaign.status != REMOVED
          AND campaign.advertising_channel_type = SEARCH
          AND segments.date = '${hourDate}'
          AND segments.hour = ${hourNum}
      `,
      fallbackQuery: `
        SELECT
          campaign.id,
          campaign.name,
          campaign.status,
          metrics.impressions,
          metrics.clicks,
          metrics.ctr,
          metrics.average_cpc,
          metrics.cost_micros,
          metrics.conversions,
          segments.date,
          segments.hour
        FROM campaign
        WHERE campaign.status != REMOVED
          AND campaign.advertising_channel_type = SEARCH
          AND segments.date = '${hourDate}'
          AND segments.hour = ${hourNum}
      `,
    },
    {
      tab: 'L20_keyword_country_prev_hour',
      minCols: 13,
      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,
          segments.date,
          segments.hour
        FROM keyword_view
        WHERE campaign.status != REMOVED
          AND ad_group_criterion.status != REMOVED
          AND segments.date = '${hourDate}'
          AND segments.hour = ${hourNum}
          AND metrics.clicks > 0
      `,
      fallbackQuery: `
        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,
          segments.date
        FROM keyword_view
        WHERE campaign.status != REMOVED
          AND ad_group_criterion.status != REMOVED
          AND segments.date = '${hourDate}'
          AND metrics.clicks > 0
      `,
    },
    {
      tab: CONFIG.TODAY_CAMPAIGN_SHEET,
      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: CONFIG.TODAY_DETAIL_SHEET,
      minCols: 13,
      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,
          segments.geo_target_country,
          metrics.clicks,
          metrics.average_cpc,
          metrics.cost_micros,
          metrics.conversions
        FROM keyword_view
        WHERE campaign.status != REMOVED
          AND campaign.advertising_channel_type = SEARCH
          AND ad_group_criterion.status != REMOVED
          AND segments.date DURING TODAY
          AND metrics.clicks > 0
      `,
      fallbackQuery: `
        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,
          metrics.conversions
        FROM keyword_view
        WHERE campaign.status != REMOVED
          AND campaign.advertising_channel_type = SEARCH
          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.fallbackQuery) return '';
  const msg = safeString(err).toUpperCase();
  if (
    msg.indexOf('BAD_FIELD_NAME') < 0 &&
    msg.indexOf('UNRECOGNIZED') < 0 &&
    msg.indexOf('INVALID') < 0 &&
    msg.indexOf('PROHIBITED_SEGMENT') < 0 &&
    msg.indexOf('INCOMPATIBLE') < 0
  ) {
    return '';
  }
  return safeString(job.fallbackQuery);
}

function aggregateCampaignSheet(sh, includeShareMetrics) {
  const empty = {
    campaigns: 0,
    nonzeroCampaigns: 0,
    impressions: 0,
    clicks: 0,
    costMicros: 0,
    conversions: 0,
    ctr: 0,
    avgCpc: 0,
    searchIS: null,
    rankLostIS: null,
    budgetLostIS: null,
  };
  if (!sh || sh.getLastRow() < 1) return empty;

  const vals = sh.getDataRange().getValues();
  if (!vals || vals.length === 0 || !vals[0]) return empty;
  const h = vals[0];
  if (
    h.indexOf('metrics.impressions') < 0 ||
    h.indexOf('metrics.clicks') < 0 ||
    h.indexOf('metrics.cost_micros') < 0 ||
    h.indexOf('metrics.conversions') < 0
  ) {
    return empty;
  }
  const iImpr = findCol(h, 'metrics.impressions');
  const iClk = findCol(h, 'metrics.clicks');
  const iCost = findCol(h, 'metrics.cost_micros');
  const iConv = findCol(h, 'metrics.conversions');
  const iSearchIS = includeShareMetrics ? findColOptional(h, 'metrics.search_impression_share') : -1;
  const iRankLostIS = includeShareMetrics ? findColOptional(h, 'metrics.search_rank_lost_impression_share') : -1;
  const iBudgetLostIS = includeShareMetrics ? findColOptional(h, 'metrics.search_budget_lost_impression_share') : -1;

  let campaigns = 0;
  let nonzeroCampaigns = 0;
  let impressions = 0;
  let clicks = 0;
  let costMicros = 0;
  let conversions = 0;
  let weightedImprForShare = 0;
  let sumSearchIS = 0;
  let sumRankLostIS = 0;
  let sumBudgetLostIS = 0;

  for (let i = 1; i < vals.length; i++) {
    const row = vals[i];
    const impr = toNumber(row[iImpr]);
    const clk = toNumber(row[iClk]);
    const cost = toNumber(row[iCost]);
    const conv = toNumber(row[iConv]);

    campaigns++;
    if (impr > 0 || clk > 0 || cost > 0) nonzeroCampaigns++;
    impressions += impr;
    clicks += clk;
    costMicros += cost;
    conversions += conv;

    if (includeShareMetrics && impr > 0) {
      weightedImprForShare += impr;
      if (iSearchIS >= 0) sumSearchIS += normalizeShareMetric(row[iSearchIS]) * impr;
      if (iRankLostIS >= 0) sumRankLostIS += normalizeShareMetric(row[iRankLostIS]) * impr;
      if (iBudgetLostIS >= 0) sumBudgetLostIS += normalizeShareMetric(row[iBudgetLostIS]) * impr;
    }
  }

  return {
    campaigns: campaigns,
    nonzeroCampaigns: nonzeroCampaigns,
    impressions: impressions,
    clicks: clicks,
    costMicros: costMicros,
    conversions: conversions,
    ctr: impressions > 0 ? clicks / impressions : 0,
    avgCpc: clicks > 0 ? (costMicros / 1000000) / clicks : 0,
    searchIS: includeShareMetrics && weightedImprForShare > 0 ? sumSearchIS / weightedImprForShare : null,
    rankLostIS: includeShareMetrics && weightedImprForShare > 0 ? sumRankLostIS / weightedImprForShare : null,
    budgetLostIS: includeShareMetrics && weightedImprForShare > 0 ? sumBudgetLostIS / weightedImprForShare : null,
  };
}

function aggregateTodayFromAdsApi() {
  const out = {
    impressions: 0,
    clicks: 0,
    costMicros: 0,
    conversions: 0,
  };
  const q = `
    SELECT
      metrics.impressions,
      metrics.clicks,
      metrics.cost_micros,
      metrics.conversions
    FROM campaign
    WHERE campaign.status != 'REMOVED'
      AND segments.date DURING TODAY
  `;
  const it = AdsApp.search(q);
  while (it.hasNext()) {
    const row = it.next();
    out.impressions += toNumber(row.metrics && row.metrics.impressions);
    out.clicks += toNumber(row.metrics && row.metrics.clicks);
    out.costMicros += toNumber(row.metrics && row.metrics.costMicros);
    out.conversions += toNumber(row.metrics && row.metrics.conversions);
  }
  return out;
}

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

  const hourAgg = aggregateCampaignSheet(
    ss.getSheetByName('L10_campaign_prev_hour'),
    true
  );
  const todayAgg = aggregateTodayFromAdsApi();
  const hourSearchIS =
    hourAgg.searchIS === null ? 'n/a' : Number(hourAgg.searchIS).toFixed(4);
  const hourRankLostIS =
    hourAgg.rankLostIS === null ? 'n/a' : Number(hourAgg.rankLostIS).toFixed(4);
  const hourBudgetLostIS =
    hourAgg.budgetLostIS === null
      ? 'n/a'
      : Number(hourAgg.budgetLostIS).toFixed(4);
  const hourDate = safeString(prevHourWindow && prevHourWindow.date);
  const hourNum = Number(prevHourWindow && prevHourWindow.hour);
  const hourLabel =
    (prevHourWindow && prevHourWindow.label) ||
    `${hourDate} ${String(hourNum)}:00`;

  var summaryRows = [
    ['run_id', runId, 'unique run id'],
    ['account_id', String(account.getCustomerId()), 'google ads customer id'],
    ['account_name', safeString(account.getName()), 'google ads account name'],
    ['account_timezone', account.getTimeZone(), 'account timezone'],
    ['exported_at', formatDate(new Date(), tz), 'snapshot time'],
    ['summary_version', '2026-03-06-v3', 'hourly-lite summary format version'],
    ['hour_window', hourLabel, 'strict previous full hour'],
    ['hour_date', hourDate, 'segments.date'],
    ['hour_of_day', hourNum, 'segments.hour (0-23)'],
    ['prev_hour_campaigns', hourAgg.campaigns, 'campaigns in previous hour'],
    [
      'prev_hour_nonzero_campaigns',
      hourAgg.nonzeroCampaigns,
      'campaigns with traffic in previous hour',
    ],
    [
      'prev_hour_impressions',
      Math.round(hourAgg.impressions),
      'sum metrics.impressions',
    ],
    ['prev_hour_clicks', Math.round(hourAgg.clicks), 'sum metrics.clicks'],
    ['prev_hour_ctr', hourAgg.ctr.toFixed(4), 'clicks/impressions'],
    [
      'prev_hour_cost',
      (hourAgg.costMicros / 1000000).toFixed(2),
      'account currency',
    ],
    ['prev_hour_avg_cpc', hourAgg.avgCpc.toFixed(4), 'cost/clicks'],
    [
      'prev_hour_conversions',
      hourAgg.conversions.toFixed(2),
      'sum conversions',
    ],
    [
      'prev_hour_search_impression_share',
      hourSearchIS,
      'impression-weighted avg (0-1)',
    ],
    [
      'prev_hour_search_lost_is_rank',
      hourRankLostIS,
      'impression-weighted avg (0-1)',
    ],
    [
      'prev_hour_search_lost_is_budget',
      hourBudgetLostIS,
      'impression-weighted avg (0-1)',
    ],
    ['today_impressions', Math.round(todayAgg.impressions), 'sum metrics.impressions DURING TODAY'],
    ['today_clicks', Math.round(todayAgg.clicks), 'sum metrics.clicks DURING TODAY'],
    ['today_cost', (todayAgg.costMicros / 1000000).toFixed(2), 'sum cost DURING TODAY (account currency)'],
    ['today_conversions', todayAgg.conversions.toFixed(2), 'sum conversions DURING TODAY'],
    ['today_metrics_source', 'AdsApp.search campaign DURING TODAY', 'authoritative today snapshot source'],
    // Backward-compatible aliases for old consumers.
    ['today_impr', Math.round(todayAgg.impressions), 'alias of today_impressions'],
    ['today_conv', todayAgg.conversions.toFixed(2), 'alias of today_conversions'],
  ];

  out.getRange(2, 1, summaryRows.length, 3).setValues(summaryRows);
  trimLiteSheet(out, 3);

  return {
    runId: runId,
    accountId: String(account.getCustomerId()),
    accountName: safeString(account.getName()),
    accountTimezone: account.getTimeZone(),
    exportedAt: formatDate(new Date(), tz),
    prevHourLabel: hourLabel,
    prevHourDate: hourDate,
    prevHourNum: hourNum,
    prevHourImpr: Math.round(hourAgg.impressions),
    prevHourClk: Math.round(hourAgg.clicks),
    prevHourCost: (hourAgg.costMicros / 1000000).toFixed(2),
    prevHourConv: hourAgg.conversions.toFixed(2),
    prevHourSearchIS: hourSearchIS,
    prevHourRankLostIS: hourRankLostIS,
    prevHourBudgetLostIS: hourBudgetLostIS,
    todayImpr: Math.round(todayAgg.impressions),
    todayClk: Math.round(todayAgg.clicks),
    todayCost: (todayAgg.costMicros / 1000000).toFixed(2),
    todayConv: todayAgg.conversions.toFixed(2),
    todaySearchIS: 'n/a',
    todayRankLostIS: 'n/a',
    todayBudgetLostIS: 'n/a',
    policyIssueRate: 'n/a',
  };
}

function buildLiteFeishuText(summary, timelineContext) {
  const ctx = timelineContext || {
    hourDelta: null,
    recentHourlyLine: '',
    monitorCumulative: null,
    regressionDetected: false,
    segmentResetCount: 0,
  };
  const lines = [
    '[Google Ads Hourly Lite]',
    `run_id: ${summary.runId}`,
    `account: ${summary.accountName || summary.accountId} (${summary.accountId})`,
    `exported_at: ${summary.exportedAt}`,
    `上一小时窗口: ${summary.prevHourLabel}`,
    `上一小时: 曝光 ${formatInt(summary.prevHourImpr)} | 点击 ${formatInt(summary.prevHourClk)} | 花费 ${formatDecimal(summary.prevHourCost, 2)}`,
    `今日累计快照: 曝光 ${formatInt(summary.todayImpr)} | 点击 ${formatInt(summary.todayClk)} | 花费 ${formatDecimal(summary.todayCost, 2)}`,
  ];
  if (toNumber(summary.todayConv) > 0) {
    lines.push(`今日累计转化: ${formatDecimal(summary.todayConv, 2)}`);
  }
  if (ctx.hourDelta) {
    lines.push(
      `较上次小时快照新增: 曝光 ${formatInt(ctx.hourDelta.impressions)} | 点击 ${formatInt(ctx.hourDelta.clicks)} | 花费 ${formatDecimal(ctx.hourDelta.cost, 2)}`
    );
  }
  if (ctx.regressionDetected) {
    lines.push(
      '警告: 今日累计快照较上一条小时快照出现回退,已把小时监控累计从当前快照重新分段,避免把旧脏数据继续滚入今天累计。'
    );
  }
  if (CONFIG.INCLUDE_HOURLY_TIMELINE_LINE && ctx.recentHourlyLine) {
    lines.push(ctx.recentHourlyLine);
  }
  if (
    CONFIG.INCLUDE_MONITOR_CUMULATIVE_LINE &&
    ctx.monitorCumulative &&
    (toNumber(ctx.monitorCumulative.impressions) > 0 ||
      toNumber(ctx.monitorCumulative.clicks) > 0 ||
      toNumber(ctx.monitorCumulative.cost) > 0 ||
      toNumber(ctx.monitorCumulative.conversions) > 0)
  ) {
    lines.push(
      `今日小时监控累计: 曝光 ${formatInt(ctx.monitorCumulative.impressions)} | 点击 ${formatInt(ctx.monitorCumulative.clicks)} | 花费 ${formatDecimal(ctx.monitorCumulative.cost, 2)}`
    );
  }
  return lines.join('\n');
}

function buildLiteFallbackFeishuText(runId, account, tz, reason) {
  const nowText = formatDate(new Date(), tz);
  return [
    '[Google Ads Hourly Lite] FALLBACK',
    `run_id: ${safeString(runId)}`,
    `time: ${nowText}`,
    `account_id: ${safeString(account && account.getCustomerId && account.getCustomerId())}`,
    `account_name: ${safeString(account && account.getName && account.getName())}`,
    `reason: ${safeString(reason) || 'UNKNOWN'}`,
    'note: summary build failed or empty; check 00_RUN_LOG_LITE and 90_LITE_SUMMARY immediately.',
  ].join('\n');
}

function sendFeishuText(text) {
  const prop = PropertiesService.getScriptProperties();
  const resolved = resolveFeishuTarget(prop);
  const webhook = resolved.webhook;
  const token = resolved.token;
  const credentialSource = resolved.source;
  if (!webhook) {
    Logger.log('Feishu skipped: reason=NO_WEBHOOK source=' + credentialSource);
    return { sent: false, code: 0, reason: 'NO_WEBHOOK', source: credentialSource };
  }
  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;
  }

  try {
    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) +
          ', source=' +
          credentialSource +
          ', serverDate=' +
          serverDate +
          ', body=' +
          body
      );
      return {
        sent: false,
        code: code,
        appCode: appCode,
        appMsg: appMsg,
        body: body,
        source: credentialSource,
        reason: 'API_REJECTED',
      };
    }
    return { sent: true, code: code, appCode: appCode, appMsg: appMsg, source: credentialSource };
  } catch (e) {
    Logger.log('Feishu send exception: ' + String(e));
    return {
      sent: false,
      code: -1,
      error: String(e),
      source: credentialSource,
      reason: 'EXCEPTION',
    };
  }
}

function resolveFeishuTarget(prop) {
  const pWebhook = safeString(prop.getProperty('RAC_FEISHU_WEBHOOK_URL')).trim();
  const pToken = safeString(prop.getProperty('RAC_FEISHU_TOKEN')).trim();
  const cWebhook = safeString(
    CONFIG.RAC_FEISHU_WEBHOOK_URL || CONFIG.FEISHU_WEBHOOK_URL
  ).trim();
  const cToken = safeString(CONFIG.RAC_FEISHU_TOKEN || CONFIG.FEISHU_TOKEN).trim();

  if (pWebhook && pToken) {
    return { webhook: pWebhook, token: pToken, source: 'script_properties_pair' };
  }
  if (cWebhook && cToken) {
    return { webhook: cWebhook, token: cToken, source: 'config_pair' };
  }
  if (pWebhook && !pToken) {
    return { webhook: pWebhook, token: '', source: 'script_properties_webhook_only' };
  }
  if (cWebhook && !cToken) {
    return { webhook: cWebhook, token: '', source: 'config_webhook_only' };
  }
  return { webhook: pWebhook || cWebhook || '', token: pToken || cToken || '', source: 'incomplete' };
}

function debugPingFeishu() {
  const now = Utilities.formatDate(new Date(), 'Asia/Shanghai', 'yyyy-MM-dd HH:mm:ss');
  const result = sendFeishuText('[Google Ads Hourly Lite] debug ping at ' + now);
  Logger.log('debugPingFeishu result: ' + JSON.stringify(result));
  return result;
}

function debugFeishuSecretMeta() {
  const prop = PropertiesService.getScriptProperties();
  const resolved = resolveFeishuTarget(prop);
  const webhook = safeString(resolved.webhook).trim();
  const tokenTrim = safeString(resolved.token).trim();
  const propWebhook = safeString(prop.getProperty('RAC_FEISHU_WEBHOOK_URL')).trim();
  const propToken = safeString(prop.getProperty('RAC_FEISHU_TOKEN')).trim();
  const configWebhook = safeString(
    CONFIG.RAC_FEISHU_WEBHOOK_URL || CONFIG.FEISHU_WEBHOOK_URL || ''
  ).trim();
  const configToken = safeString(CONFIG.RAC_FEISHU_TOKEN || CONFIG.FEISHU_TOKEN || '').trim();
  const out = {
    selectedSource: resolved.source,
    webhookPrefix: webhook ? webhook.substring(0, 45) : '',
    webhookSuffix: webhook ? webhook.substring(Math.max(0, webhook.length - 8)) : '',
    selectedTokenLen: tokenTrim.length,
    propWebhookLen: propWebhook.length,
    propTokenLen: propToken.length,
    configWebhookLen: configWebhook.length,
    configTokenLen: configToken.length,
  };
  Logger.log('debugFeishuSecretMeta: ' + JSON.stringify(out));
  return out;
}

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 getOrCreateHourlyTimelineSheet(ss) {
  const sh = getOrCreateSheet(ss, CONFIG.HOURLY_TIMELINE_SHEET);
  if (sh.getLastRow() === 0) {
    sh.getRange(1, 1, 1, 15).setValues([[
      'timestamp',
      'day_key',
      'run_id',
      'today_impr',
      'today_clicks',
      'today_cost',
      'today_conv',
      'delta_impr',
      'delta_clicks',
      'delta_cost',
      'delta_conv',
      'search_is',
      'rank_lost',
      'budget_lost',
      'policy_issue_rate',
    ]]);
  }
  return sh;
}

function appendHourlyTimelineAndBuildContext(ss, summary) {
  const sh = getOrCreateHourlyTimelineSheet(ss);
  const accountTimezone = safeString(summary.accountTimezone).trim();
  const exportedAtText = normalizeLiteTimestampText(
    summary.exportedAt,
    accountTimezone
  );
  const dayKey = exportedAtText.substring(0, 10);
  const prev = readLatestHourlyTimelineRow(sh);
  const current = {
    impressions: toNumber(summary.todayImpr),
    clicks: toNumber(summary.todayClk),
    cost: toNumber(summary.todayCost),
    conversions: toNumber(summary.todayConv),
  };
  const prevDayKey = prev ? safeString(prev.dayKey) : '';
  const sameDay = prev && prevDayKey === dayKey;
  const regressionDetected =
    sameDay &&
    isHourlySnapshotRegression(current, {
      impressions: prev.todayImpressions,
      clicks: prev.todayClicks,
      cost: prev.todayCost,
      conversions: prev.todayConversions,
    });
  const hourDelta = {
    impressions: regressionDetected
      ? 0
      : sameDay
      ? calcPositiveDelta(current.impressions, prev.todayImpressions)
      : current.impressions,
    clicks: regressionDetected
      ? 0
      : sameDay
      ? calcPositiveDelta(current.clicks, prev.todayClicks)
      : current.clicks,
    cost: regressionDetected
      ? 0
      : sameDay
      ? calcPositiveDelta(current.cost, prev.todayCost)
      : current.cost,
    conversions: regressionDetected
      ? 0
      : sameDay
      ? calcPositiveDelta(current.conversions, prev.todayConversions)
      : current.conversions,
  };

  sh.appendRow([
    exportedAtText,
    dayKey,
    safeString(summary.runId),
    formatInt(current.impressions),
    formatInt(current.clicks),
    formatDecimal(current.cost, 2),
    formatDecimal(current.conversions, 2),
    formatInt(hourDelta.impressions),
    formatInt(hourDelta.clicks),
    formatDecimal(hourDelta.cost, 2),
    formatDecimal(hourDelta.conversions, 2),
    safeString(summary.todaySearchIS),
    safeString(summary.todayRankLostIS),
    safeString(summary.todayBudgetLostIS),
    safeString(summary.policyIssueRate),
  ]);

  const keepRows = Math.max(200, Number(CONFIG.HOURLY_TIMELINE_KEEP_ROWS || 3000));
  const maxRows = keepRows + 1;
  if (sh.getLastRow() > maxRows) {
    sh.deleteRows(2, sh.getLastRow() - maxRows);
  }

  const segment = readLatestHourlyTimelineSegment(sh, dayKey);
  const recentRows = segment.rows.slice(
    Math.max(0, segment.rows.length - Math.max(1, Number(CONFIG.HOURLY_TIMELINE_RECENT_HOURS || 10)))
  );
  const monitorCumulative = sumHourlyTimelineRows(segment.rows);
  return {
    hourDelta: hourDelta,
    recentHourlyLine: buildRecentHourlyLine(recentRows, accountTimezone),
    monitorCumulative: monitorCumulative,
    regressionDetected: regressionDetected,
    segmentResetCount: segment.resetCount,
  };
}

function readLatestHourlyTimelineRow(sh) {
  if (!sh || sh.getLastRow() < 2) return null;
  const row = sh.getRange(sh.getLastRow(), 1, 1, 15).getValues()[0];
  return {
    dayKey: safeString(row[1]),
    todayImpressions: toNumber(row[3]),
    todayClicks: toNumber(row[4]),
    todayCost: toNumber(row[5]),
    todayConversions: toNumber(row[6]),
  };
}

function readLatestHourlyTimelineSegment(sh, dayKey) {
  if (!sh || sh.getLastRow() < 2) {
    return { rows: [], resetCount: 0 };
  }
  const targetDay = safeString(dayKey).trim();
  const values = sh.getRange(2, 1, sh.getLastRow() - 1, 15).getValues();
  const sameDayRows = targetDay
    ? values.filter(function(row) {
        return safeString(row[1]).trim() === targetDay;
      })
    : values;
  if (sameDayRows.length <= 1) {
    return { rows: sameDayRows, resetCount: 0 };
  }

  let segmentStart = 0;
  let resetCount = 0;
  for (let i = 1; i < sameDayRows.length; i++) {
    const prev = sameDayRows[i - 1];
    const current = sameDayRows[i];
    if (
      isHourlySnapshotRegression(
        {
          impressions: toNumber(current[3]),
          clicks: toNumber(current[4]),
          cost: toNumber(current[5]),
          conversions: toNumber(current[6]),
        },
        {
          impressions: toNumber(prev[3]),
          clicks: toNumber(prev[4]),
          cost: toNumber(prev[5]),
          conversions: toNumber(prev[6]),
        }
      )
    ) {
      segmentStart = i;
      resetCount++;
    }
  }
  return {
    rows: sameDayRows.slice(segmentStart),
    resetCount: resetCount,
  };
}

function sumHourlyTimelineRows(rows) {
  if (!rows || rows.length === 0) {
    return { impressions: 0, clicks: 0, cost: 0, conversions: 0 };
  }
  let impr = 0;
  let clicks = 0;
  let cost = 0;
  let conv = 0;
  for (let i = 0; i < rows.length; i++) {
    impr += toNumber(rows[i][7]);
    clicks += toNumber(rows[i][8]);
    cost += toNumber(rows[i][9]);
    conv += toNumber(rows[i][10]);
  }
  return {
    impressions: impr,
    clicks: clicks,
    cost: cost,
    conversions: conv,
  };
}

function buildRecentHourlyLine(rows, tz) {
  if (!rows || rows.length === 0) return '';
  const changed = rows.filter(function(row) {
    return toNumber(row[8]) > 0 || toNumber(row[9]) > 0 || toNumber(row[10]) > 0;
  });
  const last = rows[rows.length - 1];
  const lastTotalClicks = last ? formatInt(last[4]) : '0';
  const lastTotalCost = last ? formatDecimal(last[5], 2) : '0.00';
  if (changed.length === 0) {
    return `最近${String(rows.length)}次小时轨迹: 无新增点击 | 当前总点击 ${lastTotalClicks} | 当前总花费 ${lastTotalCost}`;
  }
  const parts = changed.slice(-4).map(function(row) {
    const ts = normalizeLiteTimestampText(row[0], tz);
    const hh = ts.length >= 13 ? ts.substring(11, 13) : '--';
    return `${hh}:00 +${formatInt(row[8])}clk/$${formatDecimal(row[9], 2)}`;
  });
  return `最近${String(rows.length)}次小时轨迹: ${parts.join(' | ')} | 当前总点击 ${lastTotalClicks} | 当前总花费 ${lastTotalCost}`;
}

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 normalizeLiteTimestampText(value, tz) {
  const zone = safeString(tz).trim() || 'America/Los_Angeles';
  if (value instanceof Date) {
    return Utilities.formatDate(value, zone, 'yyyy-MM-dd HH:mm:ss');
  }
  const text = safeString(value).trim();
  if (!text) return '';
  const standard = text.match(
    /^(\d{4}-\d{2}-\d{2})(?:[ T](\d{2}:\d{2}:\d{2}))?$/
  );
  if (standard) {
    return `${standard[1]} ${standard[2] || '00:00:00'}`;
  }
  const parsed = new Date(text);
  if (!isNaN(parsed.getTime())) {
    return Utilities.formatDate(parsed, zone, 'yyyy-MM-dd HH:mm:ss');
  }
  return text;
}

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 formatInt(v) {
  return String(Math.max(0, Math.round(toNumber(v))));
}

function formatDecimal(v, digits) {
  const n = toNumber(v);
  if (!isFinite(n)) return '0';
  return n.toFixed(digits);
}

function calcPositiveDelta(currentValue, previousValue) {
  const c = toNumber(currentValue);
  const p = toNumber(previousValue);
  if (!isFinite(c) || !isFinite(p)) return 0;
  if (c <= p) return 0;
  return c - p;
}

function isMeaningfulHourlyDrop(currentValue, previousValue, absoluteFloor, ratioFloor) {
  const current = toNumber(currentValue);
  const previous = toNumber(previousValue);
  if (!isFinite(current) || !isFinite(previous) || previous <= 0) return false;
  const drop = previous - current;
  if (drop <= 0) return false;
  return drop >= Math.max(absoluteFloor, previous * ratioFloor);
}

function isHourlySnapshotRegression(currentSnapshot, previousSnapshot) {
  return (
    isMeaningfulHourlyDrop(
      currentSnapshot && currentSnapshot.clicks,
      previousSnapshot && previousSnapshot.clicks,
      3,
      0.2
    ) ||
    isMeaningfulHourlyDrop(
      currentSnapshot && currentSnapshot.impressions,
      previousSnapshot && previousSnapshot.impressions,
      10,
      0.2
    ) ||
    isMeaningfulHourlyDrop(
      currentSnapshot && currentSnapshot.cost,
      previousSnapshot && previousSnapshot.cost,
      0.5,
      0.2
    )
  );
}

function normalizeShareMetric(v) {
  const n = toNumber(v);
  if (n <= 0) return 0;
  if (n <= 1) return n;
  if (n <= 100) return n / 100;
  return 1;
}

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

function findColOptional(headers, name) {
  return headers.indexOf(name);
}

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