知识库首页 广告 01_every-minute-feishu-monitor_apps-script.txt

01 every minute feishu monitor apps script

本地来源:广告/ads-scripts/01_every-minute-feishu-monitor_apps-script.txt

// IMPORTANT:
// 1) This file is for script.google.com (Apps Script) only.
// 2) Do NOT use this file in ads.google.com Scripts list (Google Ads Scripts requires main()).
// 3) Paste this file as-is into ONE project; do NOT wrap with `function main() { ... }`.
// 4) Do NOT mix 01/02/03 in the same project.

const MINUTE_MONITOR_CONFIG = {
  // 存放分钟监控内部状态(比如 99_MM_CLICK_STATE)
  STATE_SPREADSHEET_URL:
    'https://docs.google.com/spreadsheets/d/1IRVoni0t8VQc2u9G35ajuC28vRyCBiJ3wYULpnipDwI/edit',
  // 读取 hourly-lite 输出的汇总与关键词明细
  SOURCE_SPREADSHEET_URL:
    'https://docs.google.com/spreadsheets/d/1ak5fsF8J4kZYh_IQdTrMI0Goeus9ToV6j9kd8xJ_suI/edit',
  SUMMARY_SHEET: '90_LITE_SUMMARY',
  DETAIL_SHEET: 'L20_keyword_country_today',
  STATE_SHEET: '99_MM_CLICK_STATE',
  HEARTBEAT_EVERY_MINUTES: 30,
  DETAIL_TOP_N: 12,
  ENABLE_FEISHU: true,
  RAC_FEISHU_WEBHOOK_URL: 'https://open.feishu.cn/open-apis/bot/v2/hook/2c4db3a0-7bdc-42a6-bc0e-44da7344fe87',
  RAC_FEISHU_TOKEN: 'oRbJgofj3viHwkzjflTozh',
};

/**
 * Apps Script 时间触发器每分钟跑一次。
 * 说明:
 * - Google Ads 报表并非实时流;本脚本是在每分钟轮询“最新快照”
 * - 当 run_id 变化(通常来自 hourly-lite 新跑完)时,计算关键词维度新增点击并推送
 */
function minuteHeartbeat() {
  Logger.log('[MinuteMonitor] heartbeat start');
  const now = new Date();
  const sourceSs = openSourceSpreadsheet();
  const map = readSummaryMap(sourceSs);
  const digest = buildDigest(map);
  const runId = safeString(map.run_id);
  const props = PropertiesService.getScriptProperties();

  const lastDigest = props.getProperty('MM_LAST_DIGEST') || '';
  const lastRunId = props.getProperty('MM_LAST_RUN_ID') || '';
  const lastSendMs = Number(props.getProperty('MM_LAST_SEND_MS') || '0');
  const lastTodayClicks = Number(props.getProperty('MM_LAST_TODAY_CLICKS') || '0');
  const elapsedMin = lastSendMs > 0 ? (now.getTime() - lastSendMs) / 60000 : 99999;
  const currentTodayClicks = toNumber(map.today_clicks);

  const changedSummary = digest !== lastDigest;
  const runIdChanged = Boolean(runId && runId !== lastRunId);
  const heartbeatDue = elapsedMin >= MINUTE_MONITOR_CONFIG.HEARTBEAT_EVERY_MINUTES;
  const clicksIncreased = currentTodayClicks > lastTodayClicks;

  let detailResult = null;
  if (runIdChanged) {
    const stateSs = openStateSpreadsheet();
    detailResult = computeClickDeltaAndPersist(sourceSs, stateSs, runId);
  }

  if (!changedSummary && !runIdChanged && !heartbeatDue && !clicksIncreased) {
    Logger.log(
      '[MinuteMonitor] skip send: runId=' +
        runId +
        ', lastRunId=' +
        lastRunId +
        ', changedSummary=' +
        changedSummary +
        ', runIdChanged=' +
        runIdChanged +
        ', heartbeatDue=' +
        heartbeatDue +
        ', clicksIncreased=' +
        clicksIncreased +
        ', currentTodayClicks=' +
        currentTodayClicks +
        ', lastTodayClicks=' +
        lastTodayClicks
    );
    return;
  }

  const mode = runIdChanged
    ? 'NEW_RUN'
    : clicksIncreased
      ? 'CLICK_UP'
      : changedSummary
      ? 'CHANGE'
      : 'HEARTBEAT';

  const text = buildFeishuText(map, mode, detailResult);
  if (MINUTE_MONITOR_CONFIG.ENABLE_FEISHU) {
    const sendResult = sendFeishuText(text);
    Logger.log(
      '[MinuteMonitor] send result: mode=' +
        mode +
        ', runId=' +
        runId +
        ', changedSummary=' +
        changedSummary +
        ', runIdChanged=' +
        runIdChanged +
        ', heartbeatDue=' +
        heartbeatDue +
        ', clicksIncreased=' +
        clicksIncreased +
        ', currentTodayClicks=' +
        currentTodayClicks +
        ', lastTodayClicks=' +
        lastTodayClicks +
        ', sent=' +
        String(sendResult && sendResult.sent) +
        ', code=' +
        String(sendResult && sendResult.code)
    );
  }
  Logger.log('[MinuteMonitor] heartbeat end');

  props.setProperty('MM_LAST_DIGEST', digest);
  props.setProperty('MM_LAST_RUN_ID', runId);
  props.setProperty('MM_LAST_SEND_MS', String(now.getTime()));
  props.setProperty('MM_LAST_TODAY_CLICKS', String(currentTodayClicks));
}

/** 一键安装“每分钟触发器” */
function installMinuteTrigger() {
  clearMinuteTriggers();
  ScriptApp.newTrigger('minuteHeartbeat')
    .timeBased()
    .everyMinutes(1)
    .inTimezone('Asia/Shanghai')
    .create();
}

/** 清理当前项目内 minuteHeartbeat 的触发器 */
function clearMinuteTriggers() {
  const all = ScriptApp.getProjectTriggers();
  for (let i = 0; i < all.length; i++) {
    if (all[i].getHandlerFunction() === 'minuteHeartbeat') {
      ScriptApp.deleteTrigger(all[i]);
    }
  }
}

/**
 * 首次启用前执行一次:用当前快照建立基线,避免首次告警推送大量历史增量。
 */
function initializeMinuteMonitorBaseline() {
  const sourceSs = openSourceSpreadsheet();
  const stateSs = openStateSpreadsheet();
  const map = readSummaryMap(sourceSs);
  const runId = safeString(map.run_id);
  const digest = buildDigest(map);

  const detailMap = readKeywordDetailMap(sourceSs);
  const stateSh = getOrCreateStateSheet(stateSs);
  persistStateMap(stateSh, detailMap, runId);

  const props = PropertiesService.getScriptProperties();
  props.setProperty('MM_LAST_RUN_ID', runId);
  props.setProperty('MM_LAST_DIGEST', digest);
  props.setProperty('MM_LAST_SEND_MS', String(new Date().getTime()));
  props.setProperty('MM_LAST_TODAY_CLICKS', String(toNumber(map.today_clicks)));
}

function readSummaryMap(ss) {
  const sh = ss.getSheetByName(MINUTE_MONITOR_CONFIG.SUMMARY_SHEET);
  if (!sh || sh.getLastRow() < 2) {
    return {
      run_id: '',
      exported_at: '',
      today_impressions: '0',
      today_clicks: '0',
      today_cost: '0',
      today_conversions: '0',
      disapproved_ads: '0',
      approved_limited_ads: '0',
      policy_issue_rate: '0',
      note: 'summary sheet missing or empty',
    };
  }

  const values = sh.getRange(2, 1, sh.getLastRow() - 1, 3).getValues();
  const map = {};
  for (let i = 0; i < values.length; i++) {
    const k = safeString(values[i][0]).trim();
    if (!k) continue;
    map[k] = safeString(values[i][1]);
  }
  return map;
}

function computeClickDeltaAndPersist(sourceSs, stateSs, runId) {
  const detailMap = readKeywordDetailMap(sourceSs);
  const stateSh = getOrCreateStateSheet(stateSs);
  const prev = readStateMap(stateSh);

  const deltas = [];
  let totalDeltaClicks = 0;

  const keys = Object.keys(detailMap);
  for (let i = 0; i < keys.length; i++) {
    const key = keys[i];
    const curr = detailMap[key];
    const prevClicks = prev[key] ? toNumber(prev[key].clicks) : 0;
    const delta = curr.clicks - prevClicks;
    if (delta <= 0) continue;

    totalDeltaClicks += delta;
    deltas.push({
      delta: delta,
      campaign: curr.campaign,
      keyword: curr.keyword,
      country: curr.country,
      url: curr.url,
      avgCpc: curr.avgCpc,
      clicks: curr.clicks,
    });
  }

  deltas.sort(function (a, b) {
    if (b.delta !== a.delta) return b.delta - a.delta;
    if (b.clicks !== a.clicks) return b.clicks - a.clicks;
    return a.campaign < b.campaign ? -1 : 1;
  });

  persistStateMap(stateSh, detailMap, runId);

  return {
    detailRows: keys.length,
    deltaClicks: totalDeltaClicks,
    topRows: deltas.slice(0, MINUTE_MONITOR_CONFIG.DETAIL_TOP_N),
  };
}

function openSourceSpreadsheet() {
  const url =
    MINUTE_MONITOR_CONFIG.SOURCE_SPREADSHEET_URL ||
    MINUTE_MONITOR_CONFIG.STATE_SPREADSHEET_URL;
  if (!url) throw new Error('SOURCE_SPREADSHEET_URL is required');
  return SpreadsheetApp.openByUrl(url);
}

function openStateSpreadsheet() {
  const url =
    MINUTE_MONITOR_CONFIG.STATE_SPREADSHEET_URL ||
    MINUTE_MONITOR_CONFIG.SOURCE_SPREADSHEET_URL;
  if (!url) throw new Error('STATE_SPREADSHEET_URL is required');
  return SpreadsheetApp.openByUrl(url);
}

function readKeywordDetailMap(ss) {
  const sh = ss.getSheetByName(MINUTE_MONITOR_CONFIG.DETAIL_SHEET);
  if (!sh || sh.getLastRow() < 2) {
    return {};
  }

  const values = sh.getDataRange().getValues();
  const h = values[0];

  const idxCampaignId = findColOptional(h, 'campaign.id');
  const idxCampaignName = findColOptional(h, 'campaign.name');
  const idxAdGroupId = findColOptional(h, 'ad_group.id');
  const idxCriterionId = findColOptional(h, 'ad_group_criterion.criterion_id');
  const idxKeywordText = findColOptional(h, 'ad_group_criterion.keyword.text');
  const idxMatchType = findColOptional(h, 'ad_group_criterion.keyword.match_type');
  const idxFinalUrls = findColOptional(h, 'ad_group_criterion.final_urls');
  const idxCountry = findColOptional(h, 'segments.geo_target_country');
  const idxClicks = findColOptional(h, 'metrics.clicks');
  const idxAvgCpc = findColOptional(h, 'metrics.average_cpc');

  if (idxClicks < 0) {
    return {};
  }

  const out = {};
  for (let i = 1; i < values.length; i++) {
    const row = values[i];
    const clicks = toNumber(row[idxClicks]);
    if (clicks <= 0) continue;

    const campaignId = idxCampaignId >= 0 ? safeString(row[idxCampaignId]) : '';
    const adGroupId = idxAdGroupId >= 0 ? safeString(row[idxAdGroupId]) : '';
    const criterionId = idxCriterionId >= 0 ? safeString(row[idxCriterionId]) : '';
    const country = idxCountry >= 0 ? compactCountry(row[idxCountry]) : '';
    const url = idxFinalUrls >= 0 ? parseFirstUrl(row[idxFinalUrls]) : '';

    const key = [campaignId, adGroupId, criterionId, country, url].join('|');
    const keywordText = idxKeywordText >= 0 ? safeString(row[idxKeywordText]) : '';
    const matchType = idxMatchType >= 0 ? safeString(row[idxMatchType]) : '';

    out[key] = {
      clicks: clicks,
      campaign: idxCampaignName >= 0 ? safeString(row[idxCampaignName]) : campaignId,
      keyword: keywordText + (matchType ? ' [' + matchType + ']' : ''),
      country: country,
      url: url,
      avgCpc: idxAvgCpc >= 0 ? toNumber(row[idxAvgCpc]) : 0,
    };
  }

  return out;
}

function getOrCreateStateSheet(ss) {
  let sh = ss.getSheetByName(MINUTE_MONITOR_CONFIG.STATE_SHEET);
  if (!sh) {
    sh = ss.insertSheet(MINUTE_MONITOR_CONFIG.STATE_SHEET);
    sh.hideSheet();
  }
  return sh;
}

function readStateMap(sh) {
  if (sh.getLastRow() < 2) return {};

  const values = sh.getRange(2, 1, sh.getLastRow() - 1, 2).getValues();
  const out = {};
  for (let i = 0; i < values.length; i++) {
    const key = safeString(values[i][0]);
    if (!key) continue;
    out[key] = { clicks: toNumber(values[i][1]) };
  }
  return out;
}

function persistStateMap(sh, detailMap, runId) {
  const now = Utilities.formatDate(new Date(), 'Asia/Shanghai', 'yyyy-MM-dd HH:mm:ss');
  const rows = Object.keys(detailMap).map(function (key) {
    const x = detailMap[key];
    return [
      key,
      x.clicks,
      x.campaign,
      x.keyword,
      x.country,
      x.url,
      x.avgCpc,
      runId,
      now,
    ];
  });

  sh.clearContents();
  sh.getRange(1, 1, 1, 9).setValues([[
    'key',
    'clicks',
    'campaign',
    'keyword',
    'country',
    'url',
    'avg_cpc',
    'run_id',
    'updated_at',
  ]]);

  if (rows.length > 0) {
    sh.getRange(2, 1, rows.length, 9).setValues(rows);
  }
}

function buildDigest(map) {
  const keys = [
    'run_id',
    'today_impressions',
    'today_clicks',
    'today_cost',
    'today_conversions',
    'disapproved_ads',
    'approved_limited_ads',
    'policy_issue_rate',
  ];
  const raw = keys.map((k) => `${k}=${map[k] || ''}`).join('|');
  const bytes = Utilities.computeDigest(Utilities.DigestAlgorithm.MD5, raw);
  return bytes.map((b) => ('0' + (b & 0xff).toString(16)).slice(-2)).join('');
}

function buildFeishuText(map, mode, detailResult) {
  const lines = [
    `[Ads Minute Monitor] ${mode}`,
    `run_id: ${map.run_id || ''}`,
    `exported_at: ${map.exported_at || ''}`,
    `today: impr=${map.today_impressions || '0'}, clicks=${map.today_clicks || '0'}, cost=${map.today_cost || '0'}, conv=${map.today_conversions || '0'}`,
    `policy: disapproved=${map.disapproved_ads || '0'}, approved_limited=${map.approved_limited_ads || '0'}, issue_rate=${map.policy_issue_rate || '0'}`,
    `note: data is snapshot-based (not per-click realtime stream)`,
  ];

  if (detailResult) {
    lines.push(
      `delta_clicks_since_prev_run: ${detailResult.deltaClicks} (detail_rows=${detailResult.detailRows})`
    );

    if (detailResult.topRows && detailResult.topRows.length > 0) {
      lines.push('top_delta_details:');
      for (let i = 0; i < detailResult.topRows.length; i++) {
        const x = detailResult.topRows[i];
        lines.push(
          `${i + 1}. +${x.delta} clicks | camp=${x.campaign} | kw=${x.keyword} | country=${x.country || 'N/A'} | cpc=${toFixed4(x.avgCpc)} | url=${x.url || 'N/A'}`
        );
      }
    }
  }

  if (map.note) {
    lines.push(`note2: ${map.note}`);
  }

  return lines.filter(Boolean).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) return { sent: false, code: 0, reason: 'NO_WEBHOOK' };
  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 "signature verification":
    // IMPORTANT:
    // Feishu expected formula equals Python:
    //   string_to_sign = timestamp + "\\n" + secret
    //   sign = Base64(HMAC_SHA256("", string_to_sign))
    // (NOT HMAC(string_to_sign, secret))
    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,
      };
    }
    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 };
  }
}

/**
 * 关键修复:
 * - 之前 webhook/token 分别按“各自优先级”取值,可能出现“旧 webhook + 新 token”混搭
 * - 混搭会导致 19021(签名不匹配)
 * - 现在要求优先使用同一来源的一对凭据,禁止默认混搭
 */
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(
    MINUTE_MONITOR_CONFIG.RAC_FEISHU_WEBHOOK_URL ||
      MINUTE_MONITOR_CONFIG.FEISHU_WEBHOOK_URL
  ).trim();
  const cToken = safeString(
    MINUTE_MONITOR_CONFIG.RAC_FEISHU_TOKEN || MINUTE_MONITOR_CONFIG.FEISHU_TOKEN
  ).trim();

  // 1) Script Properties 全量配置优先
  if (pWebhook && pToken) {
    return { webhook: pWebhook, token: pToken, source: 'script_properties_pair' };
  }

  // 2) 常量配置全量配置次优先
  if (cWebhook && cToken) {
    return { webhook: cWebhook, token: cToken, source: 'config_pair' };
  }

  // 3) 无签名模式(仅 webhook)
  if (pWebhook && !pToken) {
    return { webhook: pWebhook, token: '', source: 'script_properties_webhook_only' };
  }
  if (cWebhook && !cToken) {
    return { webhook: cWebhook, token: '', source: 'config_webhook_only' };
  }

  // 4) 最后兜底:如果只配置了 token 没有 webhook,仍返回空 webhook 由上层 NO_WEBHOOK 处理
  return { webhook: pWebhook || cWebhook || '', token: pToken || cToken || '', source: 'incomplete' };
}

/** 手动连通性测试:Run 一次,检查返回 sent/code */
function debugPingFeishu() {
  const now = Utilities.formatDate(new Date(), 'Asia/Shanghai', 'yyyy-MM-dd HH:mm:ss');
  const result = sendFeishuText('[Ads Minute Monitor] 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(
    MINUTE_MONITOR_CONFIG.RAC_FEISHU_WEBHOOK_URL ||
      MINUTE_MONITOR_CONFIG.FEISHU_WEBHOOK_URL ||
      ''
  ).trim();
  const configToken = safeString(
    MINUTE_MONITOR_CONFIG.RAC_FEISHU_TOKEN || MINUTE_MONITOR_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;
}

/**
 * 一键清理旧的 Script Properties 飞书凭据。
 * 用途:当你怀疑项目里残留了旧 webhook/secret 覆盖了新配置时,先运行这个函数一次。
 */
function clearRacFeishuScriptProperties() {
  const prop = PropertiesService.getScriptProperties();
  prop.deleteProperty('RAC_FEISHU_WEBHOOK_URL');
  prop.deleteProperty('RAC_FEISHU_TOKEN');
  const out = {
    cleared: true,
    selectedAfterClear: resolveFeishuTarget(prop).source,
  };
  Logger.log('clearRacFeishuScriptProperties: ' + JSON.stringify(out));
  return out;
}

/** 手动看当前触发判断状态(不发消息) */
function debugMinuteState() {
  const sourceSs = openSourceSpreadsheet();
  const map = readSummaryMap(sourceSs);
  const props = PropertiesService.getScriptProperties();

  const state = {
    runId: safeString(map.run_id),
    todayClicks: toNumber(map.today_clicks),
    lastRunId: props.getProperty('MM_LAST_RUN_ID') || '',
    lastTodayClicks: Number(props.getProperty('MM_LAST_TODAY_CLICKS') || '0'),
    lastDigest: props.getProperty('MM_LAST_DIGEST') || '',
    lastSendMs: Number(props.getProperty('MM_LAST_SEND_MS') || '0'),
    heartbeatEveryMinutes: MINUTE_MONITOR_CONFIG.HEARTBEAT_EVERY_MINUTES,
  };

  Logger.log('debugMinuteState: ' + JSON.stringify(state));
  return state;
}

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

function parseFirstUrl(v) {
  const s = safeString(v).trim();
  if (!s) return '';

  // 常见格式:["https://a.com","https://b.com"] 或 [https://a.com]
  const m = s.match(/https?:\/\/[^\],\s"]+/i);
  if (m && m[0]) return m[0];
  return s;
}

function compactCountry(v) {
  const s = safeString(v);
  if (!s) return '';
  if (s.indexOf('geoTargetConstants/') >= 0) {
    return s.split('/').pop();
  }
  return s;
}

function toFixed4(v) {
  const n = toNumber(v);
  return n.toFixed(4);
}

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;
}

本文档为站内渲染。原始文件本地路径:saas/source/ads/广告-ads-scripts-01_every-minute-feishu-monitor_apps-script-34b347.txt(仅本地保留,不入库不部署)