01 every minute feishu monitor apps script
本地来源:Knowledge/World/项目/Practice/Google-Ads/ads-data-script/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',
TIMELINE_SHEET: '98_MM_TIMELINE',
HEARTBEAT_EVERY_MINUTES: 30,
// 飞书正文中的增量窗口(用于“近X分钟”汇总)
REPORT_WINDOW_MINUTES: 30,
FAILED_SEND_RETRY_MINUTES: 5,
TIMELINE_RECENT_MINUTES: 10,
TIMELINE_KEEP_ROWS: 6000,
DETAIL_TOP_N: 5,
DETAIL_URL_MAX_LEN: 90,
ENABLE_FEISHU: true,
SUPPRESS_ZERO_POLICY_LINE: true,
INCLUDE_HISTORY_SNAPSHOT_LINE: true,
INCLUDE_TIMELINE_LINES: true,
INCLUDE_MONITOR_CUMULATIVE_LINE: 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() {
const lock = LockService.getScriptLock();
if (!lock.tryLock(3000)) {
Logger.log('[MinuteMonitor] skip: another execution is running');
return;
}
try {
Logger.log('[MinuteMonitor] heartbeat start');
const now = new Date();
const sourceSs = openSourceSpreadsheet();
const map = readSummaryMap(sourceSs);
const snapshotValidation = validateSummarySnapshot(map);
if (!snapshotValidation.ok) {
Logger.log(
'[MinuteMonitor] skip: invalid summary snapshot. reason=' +
snapshotValidation.reason +
', run_id=' +
safeString(map.run_id) +
', exported_at=' +
safeString(map.exported_at)
);
return;
}
const digest = buildDigest(map);
const runId = safeString(map.run_id);
const props = PropertiesService.getScriptProperties();
const currentAccountId = safeString(map.account_id).trim();
const currentAccountName = safeString(map.account_name).trim();
const currentAccountTimezone = safeString(map.account_timezone).trim();
const currentDayKey =
extractExportDayKey(map.exported_at, map.account_timezone) ||
localNowText().substring(0, 10);
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 lastSendAttemptMs = Number(props.getProperty('MM_LAST_SEND_ATTEMPT_MS') || '0');
const lastTodayClicks = Number(props.getProperty('MM_LAST_TODAY_CLICKS') || '0');
const lastAccountId = safeString(props.getProperty('MM_LAST_ACCOUNT_ID')).trim();
const lastDayKey = safeString(props.getProperty('MM_LAST_DAY_KEY')).trim();
const elapsedMin = lastSendMs > 0 ? (now.getTime() - lastSendMs) / 60000 : 99999;
const attemptElapsedMin =
lastSendAttemptMs > 0 ? (now.getTime() - lastSendAttemptMs) / 60000 : 99999;
const currentTodayImpressions = pickMetricNumber(map, ['today_impressions', 'today_impr']);
const currentTodayClicks = pickMetricNumber(map, ['today_clicks']);
const currentTodayCost = pickMetricNumber(map, ['today_cost']);
const currentTodayConversions = pickMetricNumber(map, ['today_conversions', 'today_conv']);
const accountChanged =
Boolean(lastAccountId && currentAccountId && currentAccountId !== lastAccountId);
const dayChanged =
Boolean(lastDayKey && currentDayKey && currentDayKey !== lastDayKey);
const baselineResetNeeded = accountChanged || dayChanged;
const hasObservedBaseline =
!baselineResetNeeded &&
safeString(props.getProperty('MM_LAST_OBS_TODAY_CLICKS')) !== '';
const lastObservedImpressions = Number(props.getProperty('MM_LAST_OBS_TODAY_IMPR') || '0');
const lastObservedClicks = Number(props.getProperty('MM_LAST_OBS_TODAY_CLICKS') || '0');
const lastObservedCost = Number(props.getProperty('MM_LAST_OBS_TODAY_COST') || '0');
const lastObservedConversions = Number(props.getProperty('MM_LAST_OBS_TODAY_CONV') || '0');
const usesDerivedTodayMetrics =
safeString(map.today_metrics_source) === 'L10_campaign_today';
const suspectTodayRegression =
!baselineResetNeeded &&
hasObservedBaseline &&
isSuspectTodayRegression(
{
impressions: currentTodayImpressions,
clicks: currentTodayClicks,
cost: currentTodayCost,
conversions: currentTodayConversions,
},
{
impressions: lastObservedImpressions,
clicks: lastObservedClicks,
cost: lastObservedCost,
conversions: lastObservedConversions,
}
);
const shouldResetBrokenZeroBaseline =
hasObservedBaseline &&
!suspectTodayRegression &&
usesDerivedTodayMetrics &&
lastObservedImpressions <= 0 &&
lastObservedClicks <= 0 &&
lastObservedCost <= 0 &&
lastObservedConversions <= 0 &&
(currentTodayImpressions > 0 ||
currentTodayClicks > 0 ||
currentTodayCost > 0 ||
currentTodayConversions > 0);
let minuteDeltaImpressions = shouldResetBrokenZeroBaseline
? 0
: hasObservedBaseline
? calcPositiveDelta(currentTodayImpressions, lastObservedImpressions)
: 0;
let minuteDeltaClicks = shouldResetBrokenZeroBaseline
? 0
: hasObservedBaseline
? calcPositiveDelta(currentTodayClicks, lastObservedClicks)
: 0;
let minuteDeltaCost = shouldResetBrokenZeroBaseline
? 0
: hasObservedBaseline
? calcPositiveDelta(currentTodayCost, lastObservedCost)
: 0;
let minuteDeltaConversions = shouldResetBrokenZeroBaseline
? 0
: hasObservedBaseline
? calcPositiveDelta(currentTodayConversions, lastObservedConversions)
: 0;
if (baselineResetNeeded || suspectTodayRegression) {
minuteDeltaImpressions = 0;
minuteDeltaClicks = 0;
minuteDeltaCost = 0;
minuteDeltaConversions = 0;
}
const prevCumImpressions = toNumber(props.getProperty('MM_CUM_IMPR_DELTA') || '0');
const prevCumClicks = toNumber(props.getProperty('MM_CUM_CLICKS_DELTA') || '0');
const prevCumCost = toNumber(props.getProperty('MM_CUM_COST_DELTA') || '0');
const prevCumConversions = toNumber(props.getProperty('MM_CUM_CONV_DELTA') || '0');
const monitorCumImpressions = baselineResetNeeded
? 0
: suspectTodayRegression
? prevCumImpressions
: prevCumImpressions + minuteDeltaImpressions;
const monitorCumClicks = baselineResetNeeded
? 0
: suspectTodayRegression
? prevCumClicks
: prevCumClicks + minuteDeltaClicks;
const monitorCumCost = baselineResetNeeded
? 0
: suspectTodayRegression
? prevCumCost
: prevCumCost + minuteDeltaCost;
const monitorCumConversions = baselineResetNeeded
? 0
: suspectTodayRegression
? prevCumConversions
: prevCumConversions + minuteDeltaConversions;
const changedSummary = digest !== lastDigest;
const runIdChanged = Boolean(runId && runId !== lastRunId);
const heartbeatDue = elapsedMin >= MINUTE_MONITOR_CONFIG.HEARTBEAT_EVERY_MINUTES;
const retryDueAfterFailure =
attemptElapsedMin >= MINUTE_MONITOR_CONFIG.FAILED_SEND_RETRY_MINUTES;
const clicksIncreased = currentTodayClicks > lastTodayClicks;
const stateSs = openStateSpreadsheet();
let detailResult = null;
if (runIdChanged) {
if (baselineResetNeeded || suspectTodayRegression) {
persistCurrentDetailState(sourceSs, stateSs, runId);
} else {
detailResult = computeClickDeltaAndPersist(sourceSs, stateSs, runId);
}
}
const mode = accountChanged
? 'ACCOUNT_SWITCH'
: dayChanged
? 'DAY_RESET'
: suspectTodayRegression
? runIdChanged
? 'NEW_RUN_SUSPECT'
: 'SUSPECT'
: runIdChanged
? 'NEW_RUN'
: clicksIncreased
? 'CLICK_UP'
: changedSummary
? 'CHANGE'
: 'HEARTBEAT';
appendMinuteTimelineRow(stateSs, {
timestamp: now,
accountTimezone: currentAccountTimezone,
runId: runId,
todayImpressions: currentTodayImpressions,
todayClicks: currentTodayClicks,
todayCost: currentTodayCost,
todayConversions: currentTodayConversions,
deltaImpressions: minuteDeltaImpressions,
deltaClicks: minuteDeltaClicks,
deltaCost: minuteDeltaCost,
deltaConversions: minuteDeltaConversions,
mode: mode,
});
if (shouldResetBrokenZeroBaseline) {
Logger.log(
'[MinuteMonitor] reset stale zero baseline after deriving today totals from L10_campaign_today'
);
}
if (accountChanged) {
Logger.log(
'[MinuteMonitor] account switch detected: prev=' +
lastAccountId +
', current=' +
currentAccountId
);
}
if (dayChanged) {
Logger.log(
'[MinuteMonitor] day rollover detected: prev=' +
lastDayKey +
', current=' +
currentDayKey
);
}
if (suspectTodayRegression) {
Logger.log(
'[MinuteMonitor] suspicious today regression: prevClicks=' +
lastObservedClicks +
', currentClicks=' +
currentTodayClicks +
', prevImpr=' +
lastObservedImpressions +
', currentImpr=' +
currentTodayImpressions
);
}
const minuteTimelineRows = readRecentMinuteTimelineRows(
stateSs,
MINUTE_MONITOR_CONFIG.TIMELINE_RECENT_MINUTES,
currentAccountTimezone
);
const recentMinuteLine = buildRecentMinuteLine(
minuteTimelineRows,
currentAccountTimezone
);
const reportWindowMinutes = Math.max(
1,
Number(
MINUTE_MONITOR_CONFIG.REPORT_WINDOW_MINUTES ||
MINUTE_MONITOR_CONFIG.HEARTBEAT_EVERY_MINUTES ||
30
)
);
const reportWindowRows = readRecentMinuteTimelineRows(
stateSs,
reportWindowMinutes,
currentAccountTimezone
);
const reportWindowDelta = sumMinuteTimelineDeltas(reportWindowRows);
const quietNoSignal =
!changedSummary &&
!runIdChanged &&
!clicksIncreased &&
!accountChanged &&
!dayChanged &&
!suspectTodayRegression;
if (quietNoSignal && (!heartbeatDue || !retryDueAfterFailure)) {
Logger.log(
'[MinuteMonitor] skip send: runId=' +
runId +
', lastRunId=' +
lastRunId +
', changedSummary=' +
changedSummary +
', runIdChanged=' +
runIdChanged +
', heartbeatDue=' +
heartbeatDue +
', retryDueAfterFailure=' +
retryDueAfterFailure +
', clicksIncreased=' +
clicksIncreased +
', currentTodayClicks=' +
currentTodayClicks +
', lastTodayClicks=' +
lastTodayClicks
);
return;
}
const text = buildFeishuText(map, mode, detailResult, {
minuteDelta: {
impressions: minuteDeltaImpressions,
clicks: minuteDeltaClicks,
cost: minuteDeltaCost,
conversions: minuteDeltaConversions,
},
windowDelta: reportWindowDelta,
windowMinutes: reportWindowMinutes,
monitorCumulative: {
impressions: monitorCumImpressions,
clicks: monitorCumClicks,
cost: monitorCumCost,
conversions: monitorCumConversions,
},
recentMinuteLine: recentMinuteLine,
currentAccountId: currentAccountId,
currentAccountName: currentAccountName,
currentAccountTimezone: currentAccountTimezone,
previousAccountId: lastAccountId,
currentDayKey: currentDayKey,
previousDayKey: lastDayKey,
accountChanged: accountChanged,
dayChanged: dayChanged,
suspectTodayRegression: suspectTodayRegression,
previousSnapshot: {
impressions: lastObservedImpressions,
clicks: lastObservedClicks,
cost: lastObservedCost,
conversions: lastObservedConversions,
},
currentSnapshot: {
impressions: currentTodayImpressions,
clicks: currentTodayClicks,
cost: currentTodayCost,
conversions: currentTodayConversions,
},
});
let sendResult = { sent: true, code: 0, reason: 'FEISHU_DISABLED' };
if (MINUTE_MONITOR_CONFIG.ENABLE_FEISHU) {
sendResult = sendFeishuText(text);
Logger.log(
'[MinuteMonitor] send result: mode=' +
mode +
', runId=' +
runId +
', changedSummary=' +
changedSummary +
', runIdChanged=' +
runIdChanged +
', heartbeatDue=' +
heartbeatDue +
', retryDueAfterFailure=' +
retryDueAfterFailure +
', clicksIncreased=' +
clicksIncreased +
', currentTodayClicks=' +
currentTodayClicks +
', lastTodayClicks=' +
lastTodayClicks +
', sent=' +
String(sendResult && sendResult.sent) +
', code=' +
String(sendResult && sendResult.code)
);
}
Logger.log('[MinuteMonitor] heartbeat end');
const sendSucceeded = Boolean(sendResult && sendResult.sent);
props.setProperty('MM_LAST_DIGEST', digest);
props.setProperty('MM_LAST_RUN_ID', runId);
props.setProperty('MM_LAST_SEND_ATTEMPT_MS', String(now.getTime()));
if (sendSucceeded) {
props.setProperty('MM_LAST_SEND_MS', String(now.getTime()));
}
if (currentAccountId) props.setProperty('MM_LAST_ACCOUNT_ID', currentAccountId);
if (currentAccountName) props.setProperty('MM_LAST_ACCOUNT_NAME', currentAccountName);
if (currentAccountTimezone) props.setProperty('MM_LAST_ACCOUNT_TZ', currentAccountTimezone);
if (currentDayKey) props.setProperty('MM_LAST_DAY_KEY', currentDayKey);
if (accountChanged || dayChanged) {
props.setProperty('MM_LAST_TODAY_CLICKS', String(currentTodayClicks));
props.setProperty('MM_LAST_OBS_TODAY_IMPR', String(currentTodayImpressions));
props.setProperty('MM_LAST_OBS_TODAY_CLICKS', String(currentTodayClicks));
props.setProperty('MM_LAST_OBS_TODAY_COST', String(currentTodayCost));
props.setProperty('MM_LAST_OBS_TODAY_CONV', String(currentTodayConversions));
props.setProperty('MM_CUM_IMPR_DELTA', '0');
props.setProperty('MM_CUM_CLICKS_DELTA', '0');
props.setProperty('MM_CUM_COST_DELTA', '0');
props.setProperty('MM_CUM_CONV_DELTA', '0');
} else if (!suspectTodayRegression) {
props.setProperty('MM_LAST_TODAY_CLICKS', String(currentTodayClicks));
props.setProperty('MM_LAST_OBS_TODAY_IMPR', String(currentTodayImpressions));
props.setProperty('MM_LAST_OBS_TODAY_CLICKS', String(currentTodayClicks));
props.setProperty('MM_LAST_OBS_TODAY_COST', String(currentTodayCost));
props.setProperty('MM_LAST_OBS_TODAY_CONV', String(currentTodayConversions));
props.setProperty('MM_CUM_IMPR_DELTA', String(monitorCumImpressions));
props.setProperty('MM_CUM_CLICKS_DELTA', String(monitorCumClicks));
props.setProperty('MM_CUM_COST_DELTA', String(monitorCumCost));
props.setProperty('MM_CUM_CONV_DELTA', String(monitorCumConversions));
}
} finally {
SpreadsheetApp.flush();
lock.releaseLock();
}
}
/** 一键安装“每分钟触发器” */
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);
const now = String(new Date().getTime());
const todayImpressions = pickMetricNumber(map, ['today_impressions', 'today_impr']);
const todayClicks = pickMetricNumber(map, ['today_clicks']);
const todayCost = pickMetricNumber(map, ['today_cost']);
const todayConversions = pickMetricNumber(map, ['today_conversions', 'today_conv']);
props.setProperty('MM_LAST_SEND_MS', now);
props.setProperty('MM_LAST_SEND_ATTEMPT_MS', now);
props.setProperty('MM_LAST_TODAY_CLICKS', String(todayClicks));
props.setProperty('MM_LAST_OBS_TODAY_IMPR', String(todayImpressions));
props.setProperty('MM_LAST_OBS_TODAY_CLICKS', String(todayClicks));
props.setProperty('MM_LAST_OBS_TODAY_COST', String(todayCost));
props.setProperty('MM_LAST_OBS_TODAY_CONV', String(todayConversions));
props.setProperty('MM_CUM_IMPR_DELTA', '0');
props.setProperty('MM_CUM_CLICKS_DELTA', '0');
props.setProperty('MM_CUM_COST_DELTA', '0');
props.setProperty('MM_CUM_CONV_DELTA', '0');
if (safeString(map.account_id).trim()) {
props.setProperty('MM_LAST_ACCOUNT_ID', safeString(map.account_id).trim());
}
if (safeString(map.account_name).trim()) {
props.setProperty('MM_LAST_ACCOUNT_NAME', safeString(map.account_name).trim());
}
if (safeString(map.account_timezone).trim()) {
props.setProperty('MM_LAST_ACCOUNT_TZ', safeString(map.account_timezone).trim());
}
const dayKey = extractExportDayKey(map.exported_at, map.account_timezone);
if (dayKey) {
props.setProperty('MM_LAST_DAY_KEY', dayKey);
}
}
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 = {};
const rawMap = {};
for (let i = 0; i < values.length; i++) {
const k = safeString(values[i][0]).trim();
if (!k) continue;
rawMap[k] = values[i][1];
map[k] = safeString(values[i][1]);
}
if (typeof rawMap.exported_at !== 'undefined') {
map.exported_at = normalizeTimestampText(
rawMap.exported_at,
safeString(rawMap.account_timezone || map.account_timezone).trim()
);
}
return enrichSummaryMapWithTodayTotals(ss, map);
}
function enrichSummaryMapWithTodayTotals(ss, map) {
const todayKeys = [
'today_impressions',
'today_clicks',
'today_cost',
'today_conversions',
];
const hasMissingTodayMetric = todayKeys.some(function (key) {
return safeString(map[key]).trim() === '';
});
const summaryToday = {
impressions: pickMetricNumber(map, ['today_impressions', 'today_impr']),
clicks: pickMetricNumber(map, ['today_clicks']),
cost: pickMetricNumber(map, ['today_cost']),
conversions: pickMetricNumber(map, ['today_conversions', 'today_conv']),
};
const derivedToday = deriveTodayTotalsFromCampaignSheet(ss);
const derivedHasSignal =
derivedToday.impressions > 0 ||
derivedToday.clicks > 0 ||
derivedToday.cost > 0 ||
derivedToday.conversions > 0;
const summaryLooksEmpty =
summaryToday.impressions <= 0 &&
summaryToday.clicks <= 0 &&
summaryToday.cost <= 0 &&
summaryToday.conversions <= 0;
if (!hasMissingTodayMetric && !(summaryLooksEmpty && derivedHasSignal)) {
return map;
}
map.today_impressions = String(derivedToday.impressions);
map.today_impr = String(derivedToday.impressions);
map.today_clicks = String(derivedToday.clicks);
map.today_cost = String(derivedToday.cost);
map.today_conversions = String(derivedToday.conversions);
map.today_conv = String(derivedToday.conversions);
map.today_metrics_source = 'L10_campaign_today';
Logger.log(
'[MinuteMonitor] derived today totals from L10_campaign_today: ' +
JSON.stringify(derivedToday)
);
return map;
}
function deriveTodayTotalsFromCampaignSheet(ss) {
const sh = ss.getSheetByName('L10_campaign_today');
if (!sh || sh.getLastRow() < 2) {
return { impressions: 0, clicks: 0, cost: 0, conversions: 0 };
}
const values = sh.getDataRange().getValues();
const header = values[0];
const idxImpr = findColOptional(header, 'metrics.impressions');
const idxClicks = findColOptional(header, 'metrics.clicks');
const idxCostMicros = findColOptional(header, 'metrics.cost_micros');
const idxConversions = findColOptional(header, 'metrics.conversions');
if (idxImpr < 0 && idxClicks < 0 && idxCostMicros < 0 && idxConversions < 0) {
return { impressions: 0, clicks: 0, cost: 0, conversions: 0 };
}
const out = { impressions: 0, clicks: 0, cost: 0, conversions: 0 };
for (let i = 1; i < values.length; i++) {
const row = values[i];
if (!row || row.length === 0) continue;
out.impressions += idxImpr >= 0 ? toNumber(row[idxImpr]) : 0;
out.clicks += idxClicks >= 0 ? toNumber(row[idxClicks]) : 0;
out.cost += idxCostMicros >= 0 ? toNumber(row[idxCostMicros]) / 1000000 : 0;
out.conversions += idxConversions >= 0 ? toNumber(row[idxConversions]) : 0;
}
return out;
}
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 persistCurrentDetailState(sourceSs, stateSs, runId) {
const detailMap = readKeywordDetailMap(sourceSs);
const stateSh = getOrCreateStateSheet(stateSs);
persistStateMap(stateSh, detailMap, runId);
return {
detailRows: Object.keys(detailMap).length,
deltaClicks: 0,
topRows: [],
};
}
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]) : '';
const normalizedMatchType = normalizeMatchType(matchType);
const landingType = classifyLandingType(url);
out[key] = {
clicks: clicks,
campaign: idxCampaignName >= 0 ? safeString(row[idxCampaignName]) : campaignId,
keyword: keywordText || '(not set)',
matchType: normalizedMatchType,
type: landingType,
country: country || 'N/A',
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 validateSummarySnapshot(map) {
const runId = safeString((map || {}).run_id).trim();
if (!runId || runId === '-') {
return { ok: false, reason: 'missing_run_id' };
}
const exportedAt = safeString((map || {}).exported_at).trim();
if (!exportedAt) {
return { ok: false, reason: 'missing_exported_at' };
}
return { ok: true, reason: 'ok' };
}
function normalizeTimestampText(value, tz) {
const zone = safeString(tz).trim() || 'Asia/Shanghai';
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 extractExportDayKey(exportedAt, tz) {
const text = normalizeTimestampText(exportedAt, tz);
const m = text.match(/^(\d{4}-\d{2}-\d{2})/);
return m ? m[1] : '';
}
function isMeaningfulDrop(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 isSuspectTodayRegression(currentSnapshot, previousSnapshot) {
return (
isMeaningfulDrop(
currentSnapshot && currentSnapshot.clicks,
previousSnapshot && previousSnapshot.clicks,
3,
0.2
) ||
isMeaningfulDrop(
currentSnapshot && currentSnapshot.impressions,
previousSnapshot && previousSnapshot.impressions,
10,
0.2
) ||
isMeaningfulDrop(
currentSnapshot && currentSnapshot.cost,
previousSnapshot && previousSnapshot.cost,
0.5,
0.2
)
);
}
function buildFeishuText(map, mode, detailResult, monitorExtras) {
const runId = safeString(map.run_id) || '-';
const accountTimezone =
safeString(map.account_timezone) ||
safeString(monitorExtras && monitorExtras.currentAccountTimezone) ||
'';
const exportedAt =
normalizeTimestampText(map.exported_at, accountTimezone) || localNowText();
const accountId =
safeString(map.account_id) ||
safeString(monitorExtras && monitorExtras.currentAccountId) ||
'-';
const accountName =
safeString(map.account_name) ||
safeString(monitorExtras && monitorExtras.currentAccountName) ||
'';
const todayImpressions = pickMetricNumber(map, ['today_impressions', 'today_impr']);
const todayClicks = pickMetricNumber(map, ['today_clicks']);
const todayCost = pickMetricNumber(map, ['today_cost']);
const todayConversions = pickMetricNumber(map, ['today_conversions', 'today_conv']);
const minuteDelta = (monitorExtras && monitorExtras.minuteDelta) || {
impressions: 0,
clicks: 0,
cost: 0,
};
const windowDelta = (monitorExtras && monitorExtras.windowDelta) || {
impressions: 0,
clicks: 0,
cost: 0,
conversions: 0,
};
const windowMinutes = Number(
(monitorExtras && monitorExtras.windowMinutes) ||
MINUTE_MONITOR_CONFIG.REPORT_WINDOW_MINUTES ||
30
);
const monitorCumulative = (monitorExtras && monitorExtras.monitorCumulative) || {
impressions: 0,
clicks: 0,
cost: 0,
conversions: 0,
};
const recentMinuteLine = safeString(
monitorExtras && monitorExtras.recentMinuteLine
).trim();
const policySummary = buildExtraPolicySummary(map);
const historyLine = buildHistorySnapshotLine(map);
const metricsSource =
safeString(map.today_metrics_source).trim() || '90_LITE_SUMMARY';
const lines = [
`[Ads Minute Monitor] ${mode}`,
`时间: ${exportedAt}`,
`Run: ${runId}`,
`账号: ${accountName ? accountName + ' ' : ''}(${accountId})${accountTimezone ? ' | 时区: ' + accountTimezone : ''}`,
`今日累计快照: 曝光 ${formatInt(todayImpressions)} | 点击 ${formatInt(todayClicks)} | 花费 ${formatDecimal(todayCost, 2)}`,
];
if (todayConversions > 0) {
lines.push(`今日累计转化: ${formatDecimal(todayConversions, 2)}`);
}
lines.push(
metricsSource === 'L10_campaign_today'
? '口径: summary 缺值,已回退到 L10_campaign_today 汇总'
: '口径: 90_LITE_SUMMARY 的 today_* 当日快照'
);
if (
windowMinutes > 0 &&
(toNumber(windowDelta.impressions) > 0 ||
toNumber(windowDelta.clicks) > 0 ||
toNumber(windowDelta.cost) > 0 ||
toNumber(windowDelta.conversions) > 0)
) {
lines.push(
`近${windowMinutes}分钟增量: 曝光 ${formatInt(windowDelta.impressions)} | 点击 ${formatInt(windowDelta.clicks)} | 花费 ${formatDecimal(windowDelta.cost, 2)} | 转化 ${formatDecimal(windowDelta.conversions, 2)}`
);
}
if (
MINUTE_MONITOR_CONFIG.INCLUDE_MONITOR_CUMULATIVE_LINE &&
(toNumber(monitorCumulative.impressions) > 0 ||
toNumber(monitorCumulative.clicks) > 0 ||
toNumber(monitorCumulative.cost) > 0 ||
toNumber(monitorCumulative.conversions) > 0)
) {
lines.push(
`监控累计增量: 曝光 ${formatInt(monitorCumulative.impressions)} | 点击 ${formatInt(monitorCumulative.clicks)} | 花费 ${formatDecimal(monitorCumulative.cost, 2)} | 转化 ${formatDecimal(monitorCumulative.conversions, 2)}`
);
}
if (MINUTE_MONITOR_CONFIG.INCLUDE_TIMELINE_LINES && recentMinuteLine) {
lines.push(recentMinuteLine);
}
if (monitorExtras && monitorExtras.accountChanged) {
lines.push(
`警告: 账号切换 ${safeString(monitorExtras.previousAccountId) || '-'} -> ${accountId},已重置本地基线;旧累计不可直接比较。`
);
}
if (monitorExtras && monitorExtras.dayChanged) {
lines.push(
`提示: 已按账号时区重置日期基线 ${safeString(monitorExtras.previousDayKey) || '-'} -> ${safeString(monitorExtras.currentDayKey) || '-'}。这通常发生在首次修正时区口径或自然跨天时,不代表广告数据回退。`
);
}
if (monitorExtras && monitorExtras.suspectTodayRegression) {
const prevSnapshot = (monitorExtras && monitorExtras.previousSnapshot) || {};
lines.push(
`警告: 今日累计快照较上次观察值回退。上次 点击 ${formatInt(prevSnapshot.clicks)} / 当前 点击 ${formatInt(todayClicks)},上次 花费 ${formatDecimal(prevSnapshot.cost, 2)} / 当前 花费 ${formatDecimal(todayCost, 2)}。这更像上游表被覆盖、账号切换或线上脚本版本漂移,不应当当作真实累计下降。`
);
}
if (historyLine) {
lines.push(historyLine);
}
if (!MINUTE_MONITOR_CONFIG.SUPPRESS_ZERO_POLICY_LINE && policySummary) {
lines.push(`政策: ${policySummary}`);
}
if (detailResult && toNumber(detailResult.deltaClicks) > 0) {
lines.push(`新增点击: +${formatInt(detailResult.deltaClicks)}`);
if (detailResult.topRows && detailResult.topRows.length > 0) {
lines.push('新增点击明细:');
for (let i = 0; i < detailResult.topRows.length; i++) {
const x = detailResult.topRows[i];
lines.push(`${i + 1}) +${formatInt(x.delta)} | CPC ${formatCpc(x.avgCpc)}`);
lines.push(` Campaign: ${safeString(x.campaign) || 'N/A'}`);
lines.push(` Keyword: ${safeString(x.keyword) || 'N/A'}`);
lines.push(` URL: ${compactDisplayUrl(x.url)}`);
}
}
}
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,
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',
};
}
}
/**
* 关键修复:
* - 之前 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),
accountId: safeString(map.account_id),
accountTimezone: safeString(map.account_timezone),
dayKey: extractExportDayKey(map.exported_at, map.account_timezone),
todayClicks: toNumber(map.today_clicks),
lastRunId: props.getProperty('MM_LAST_RUN_ID') || '',
lastAccountId: props.getProperty('MM_LAST_ACCOUNT_ID') || '',
lastDayKey: props.getProperty('MM_LAST_DAY_KEY') || '',
lastTodayClicks: Number(props.getProperty('MM_LAST_TODAY_CLICKS') || '0'),
lastDigest: props.getProperty('MM_LAST_DIGEST') || '',
lastSendMs: Number(props.getProperty('MM_LAST_SEND_MS') || '0'),
lastSendAttemptMs: Number(props.getProperty('MM_LAST_SEND_ATTEMPT_MS') || '0'),
lastObsImpressions: Number(props.getProperty('MM_LAST_OBS_TODAY_IMPR') || '0'),
lastObsClicks: Number(props.getProperty('MM_LAST_OBS_TODAY_CLICKS') || '0'),
lastObsCost: Number(props.getProperty('MM_LAST_OBS_TODAY_COST') || '0'),
lastObsConversions: Number(props.getProperty('MM_LAST_OBS_TODAY_CONV') || '0'),
cumImpressionsDelta: Number(props.getProperty('MM_CUM_IMPR_DELTA') || '0'),
cumClicksDelta: Number(props.getProperty('MM_CUM_CLICKS_DELTA') || '0'),
cumCostDelta: Number(props.getProperty('MM_CUM_COST_DELTA') || '0'),
cumConversionsDelta: Number(props.getProperty('MM_CUM_CONV_DELTA') || '0'),
heartbeatEveryMinutes: MINUTE_MONITOR_CONFIG.HEARTBEAT_EVERY_MINUTES,
failedSendRetryMinutes: MINUTE_MONITOR_CONFIG.FAILED_SEND_RETRY_MINUTES,
};
Logger.log('debugMinuteState: ' + JSON.stringify(state));
return state;
}
/**
* 修复辅助:把当前 summary 快照设为新基线,并清理 timeline 中 run_id 为空的脏行。
* 适用场景:历史上出现过 "Run: -" 的错误告警后,想一次性把状态对齐。
*/
function rebaseMinuteMonitorState() {
const sourceSs = openSourceSpreadsheet();
const stateSs = openStateSpreadsheet();
const map = readSummaryMap(sourceSs);
const snapshotValidation = validateSummarySnapshot(map);
if (!snapshotValidation.ok) {
throw new Error('cannot rebase: invalid summary snapshot, reason=' + snapshotValidation.reason);
}
const props = PropertiesService.getScriptProperties();
const nowMs = String(new Date().getTime());
const runId = safeString(map.run_id);
const digest = buildDigest(map);
const todayImpr = toNumber(map.today_impressions);
const todayClicks = toNumber(map.today_clicks);
const todayCost = toNumber(map.today_cost);
const todayConv = toNumber(map.today_conversions);
props.setProperty('MM_LAST_RUN_ID', runId);
props.setProperty('MM_LAST_DIGEST', digest);
props.setProperty('MM_LAST_SEND_MS', nowMs);
props.setProperty('MM_LAST_SEND_ATTEMPT_MS', nowMs);
props.setProperty('MM_LAST_TODAY_CLICKS', String(todayClicks));
props.setProperty('MM_LAST_OBS_TODAY_IMPR', String(todayImpr));
props.setProperty('MM_LAST_OBS_TODAY_CLICKS', String(todayClicks));
props.setProperty('MM_LAST_OBS_TODAY_COST', String(todayCost));
props.setProperty('MM_LAST_OBS_TODAY_CONV', String(todayConv));
props.setProperty('MM_CUM_IMPR_DELTA', '0');
props.setProperty('MM_CUM_CLICKS_DELTA', '0');
props.setProperty('MM_CUM_COST_DELTA', '0');
props.setProperty('MM_CUM_CONV_DELTA', '0');
if (safeString(map.account_id).trim()) {
props.setProperty('MM_LAST_ACCOUNT_ID', safeString(map.account_id).trim());
}
if (safeString(map.account_name).trim()) {
props.setProperty('MM_LAST_ACCOUNT_NAME', safeString(map.account_name).trim());
}
if (safeString(map.account_timezone).trim()) {
props.setProperty('MM_LAST_ACCOUNT_TZ', safeString(map.account_timezone).trim());
}
const dayKey = extractExportDayKey(map.exported_at, map.account_timezone);
if (dayKey) {
props.setProperty('MM_LAST_DAY_KEY', dayKey);
}
const removed = cleanInvalidTimelineRows(stateSs);
resetMinuteTimelineSheet(stateSs);
const out = {
rebased: true,
runId: runId,
digest: digest,
removedInvalidTimelineRows: removed,
};
Logger.log('rebaseMinuteMonitorState: ' + JSON.stringify(out));
return out;
}
function cleanInvalidTimelineRows(ss) {
const sh = ss.getSheetByName(MINUTE_MONITOR_CONFIG.TIMELINE_SHEET);
if (!sh || sh.getLastRow() < 2) return 0;
const lastRow = sh.getLastRow();
const values = sh.getRange(2, 1, lastRow - 1, 2).getValues();
const rowsToDelete = [];
for (let i = 0; i < values.length; i++) {
const runId = safeString(values[i][1]).trim();
if (!runId || runId === '-') {
rowsToDelete.push(i + 2);
}
}
for (let j = rowsToDelete.length - 1; j >= 0; j--) {
sh.deleteRow(rowsToDelete[j]);
}
return rowsToDelete.length;
}
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 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 getOrCreateMinuteTimelineSheet(ss) {
let sh = ss.getSheetByName(MINUTE_MONITOR_CONFIG.TIMELINE_SHEET);
if (!sh) {
sh = ss.insertSheet(MINUTE_MONITOR_CONFIG.TIMELINE_SHEET);
}
if (sh.getLastRow() === 0) {
sh.getRange(1, 1, 1, 11).setValues([[
'timestamp',
'run_id',
'mode',
'today_impr',
'today_clicks',
'today_cost',
'today_conv',
'delta_impr',
'delta_clicks',
'delta_cost',
'delta_conv',
]]);
}
return sh;
}
function appendMinuteTimelineRow(ss, row) {
const sh = getOrCreateMinuteTimelineSheet(ss);
const accountTimezone = safeString(row.accountTimezone).trim() || 'Asia/Shanghai';
sh.appendRow([
normalizeTimestampText(row.timestamp, accountTimezone) || localNowText(),
safeString(row.runId),
safeString(row.mode),
formatInt(row.todayImpressions),
formatInt(row.todayClicks),
formatDecimal(row.todayCost, 2),
formatDecimal(row.todayConversions, 2),
formatInt(row.deltaImpressions),
formatInt(row.deltaClicks),
formatDecimal(row.deltaCost, 2),
formatDecimal(row.deltaConversions, 2),
]);
const keepRows = Math.max(200, Number(MINUTE_MONITOR_CONFIG.TIMELINE_KEEP_ROWS || 6000));
const maxRows = keepRows + 1; // + header
if (sh.getLastRow() > maxRows) {
sh.deleteRows(2, sh.getLastRow() - maxRows);
}
}
function readRecentMinuteTimelineRows(ss, limit, tz) {
const sh = ss.getSheetByName(MINUTE_MONITOR_CONFIG.TIMELINE_SHEET);
if (!sh || sh.getLastRow() < 2) return [];
const rowCount = sh.getLastRow() - 1;
if (rowCount <= 0) return [];
const values = sh.getRange(2, 1, rowCount, 11).getValues();
const accountTimezone = safeString(tz).trim() || 'Asia/Shanghai';
const minutes = Math.max(1, Number(limit || 10));
const cutoffText = Utilities.formatDate(
new Date(new Date().getTime() - minutes * 60000),
accountTimezone,
'yyyy-MM-dd HH:mm:ss'
);
return values.filter(function (row) {
const ts = normalizeTimestampText(row[0], accountTimezone);
return Boolean(ts) && ts >= cutoffText;
});
}
function sumMinuteTimelineDeltas(rows) {
const out = { impressions: 0, clicks: 0, cost: 0, conversions: 0 };
if (!rows || rows.length === 0) return out;
for (let i = 0; i < rows.length; i++) {
out.impressions += toNumber(rows[i][7]);
out.clicks += toNumber(rows[i][8]);
out.cost += toNumber(rows[i][9]);
out.conversions += toNumber(rows[i][10]);
}
return out;
}
function buildRecentMinuteLine(rows, tz) {
if (!rows || rows.length === 0) return '';
const accountTimezone = safeString(tz).trim() || 'Asia/Shanghai';
const tail = rows.slice(
Math.max(0, rows.length - Math.max(1, Number(MINUTE_MONITOR_CONFIG.TIMELINE_RECENT_MINUTES || 10)))
);
const changed = tail.filter(function(row) {
return toNumber(row[8]) > 0 || toNumber(row[9]) > 0 || toNumber(row[10]) > 0;
});
const last = tail[tail.length - 1];
const lastTotalClicks = last ? formatInt(last[4]) : '0';
const lastTotalCost = last ? formatDecimal(last[5], 2) : '0.00';
if (changed.length === 0) {
return `最近${String(tail.length)}分钟: 无新增点击 | 当前总点击 ${lastTotalClicks} | 当前总花费 ${lastTotalCost}`;
}
const parts = changed.slice(-3).map(function(row) {
const ts = normalizeTimestampText(row[0], accountTimezone);
const hhmm = ts.length >= 16 ? ts.substring(11, 16) : ts;
return `${hhmm} +${formatInt(row[8])}clk/$${formatDecimal(row[9], 2)}`;
});
return `最近${String(tail.length)}分钟: ${parts.join(' | ')} | 当前总点击 ${lastTotalClicks} | 当前总花费 ${lastTotalCost}`;
}
function resetMinuteTimelineSheet(ss) {
const sh = ss.getSheetByName(MINUTE_MONITOR_CONFIG.TIMELINE_SHEET);
if (!sh) return;
sh.clearContents();
sh.getRange(1, 1, 1, 11).setValues([[
'timestamp',
'run_id',
'mode',
'today_impr',
'today_clicks',
'today_cost',
'today_conv',
'delta_impr',
'delta_clicks',
'delta_cost',
'delta_conv',
]]);
}
function localNowText() {
return Utilities.formatDate(new Date(), 'Asia/Shanghai', 'yyyy-MM-dd HH:mm:ss');
}
function formatInt(v) {
const n = Math.max(0, Math.round(toNumber(v)));
return String(n);
}
function formatDecimal(v, digits) {
const n = toNumber(v);
if (!isFinite(n)) return '0';
return n.toFixed(digits);
}
function trimTrailingZero(s) {
if (s.indexOf('.') < 0) return s;
return s.replace(/\.?0+$/, '');
}
function formatRate(v) {
const n = toNumber(v);
if (!isFinite(n)) return '0%';
if (n <= 1) return trimTrailingZero((n * 100).toFixed(2)) + '%';
return trimTrailingZero(n.toFixed(2)) + '%';
}
function formatCpc(v) {
const n = toNumber(v);
if (!isFinite(n) || n <= 0) return '0';
const normalized = n >= 1000 ? n / 1000000 : n;
return trimTrailingZero(normalized.toFixed(4));
}
function normalizeMatchType(v) {
const s = safeString(v).trim();
if (!s) return 'N/A';
return s.toUpperCase();
}
function classifyLandingType(url) {
if (!url) return 'other';
try {
const u = new URL(url);
const path = u.pathname || '/';
if (path.indexOf('/tools/') >= 0) return 'tools';
if (path.indexOf('/models/') >= 0) return 'models';
if (path.indexOf('/pricing') >= 0) return 'pricing';
if (path.indexOf('/checkout/') >= 0) return 'checkout';
return 'other';
} catch (_e) {
return 'other';
}
}
function compactDisplayUrl(url) {
const s = safeString(url).trim();
if (!s) return 'N/A';
let out = s;
try {
const u = new URL(s);
out = u.hostname + u.pathname;
} catch (_e) {}
const maxLen = Number(MINUTE_MONITOR_CONFIG.DETAIL_URL_MAX_LEN || 90);
if (out.length <= maxLen) return out;
return out.substring(0, Math.max(0, maxLen - 3)) + '...';
}
function buildExtraPolicySummary(map) {
const skip = {
run_id: true,
exported_at: true,
today_impressions: true,
today_clicks: true,
today_cost: true,
today_conversions: true,
disapproved_ads: true,
approved_limited_ads: true,
policy_issue_rate: true,
note: true,
};
const parts = [];
const keys = Object.keys(map || {});
for (let i = 0; i < keys.length; i++) {
const k = keys[i];
if (skip[k]) continue;
if (!/(policy|disapproved|approved_limited|under_review|eligible|campaign)/i.test(k)) {
continue;
}
const val = safeString(map[k]).trim();
if (!val) continue;
if (isZeroLikePolicyValue(val)) continue;
parts.push(normalizePolicyLabel(k) + ' ' + val);
if (parts.length >= 6) break;
}
return parts.join(' | ');
}
function buildHistorySnapshotLine(map) {
if (!MINUTE_MONITOR_CONFIG.INCLUDE_HISTORY_SNAPSHOT_LINE) return '';
const yesterdayKeys = ['yesterday_clicks', 'yesterday_cost', 'yesterday_conversions'];
const last7Keys = ['last7_clicks', 'last7_cost', 'last7_conversions'];
const last30Keys = [
'last30_clicks',
'last30_cost',
'last30_conversions',
'last_30d_clicks',
'last_30d_cost',
'last_30d_conversions',
];
const parts = [];
if (hasAnyMetricValue(map, yesterdayKeys)) {
const yClicks = pickMetricNumber(map, ['yesterday_clicks']);
const yCost = pickMetricNumber(map, ['yesterday_cost']);
const yConv = pickMetricNumber(map, ['yesterday_conversions']);
parts.push(
`昨日: 点击 ${formatInt(yClicks)} | 花费 ${formatDecimal(yCost, 2)} | 转化 ${formatDecimal(yConv, 2)}`
);
}
if (hasAnyMetricValue(map, last7Keys)) {
const d7Clicks = pickMetricNumber(map, ['last7_clicks']);
const d7Cost = pickMetricNumber(map, ['last7_cost']);
const d7Conv = pickMetricNumber(map, ['last7_conversions']);
parts.push(
`近7天: 点击 ${formatInt(d7Clicks)} | 花费 ${formatDecimal(d7Cost, 2)} | 转化 ${formatDecimal(d7Conv, 2)}`
);
}
if (hasAnyMetricValue(map, last30Keys)) {
const d30Clicks = pickMetricNumber(map, ['last30_clicks', 'last_30d_clicks']);
const d30Cost = pickMetricNumber(map, ['last30_cost', 'last_30d_cost']);
const d30Conv = pickMetricNumber(map, ['last30_conversions', 'last_30d_conversions']);
parts.push(
`近30天: 点击 ${formatInt(d30Clicks)} | 花费 ${formatDecimal(d30Cost, 2)} | 转化 ${formatDecimal(d30Conv, 2)}`
);
}
if (parts.length === 0) return '';
return '历史: ' + parts.join(' || ');
}
function hasAnyMetricValue(map, keys) {
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
const raw = safeString((map || {})[key]).trim();
if (raw !== '') return true;
}
return false;
}
function pickMetricNumber(map, keys) {
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
const raw = safeString((map || {})[key]).trim();
if (raw !== '') return toNumber(raw);
}
return 0;
}
function normalizePolicyLabel(k) {
return safeString(k)
.replace(/_/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function isZeroLikePolicyValue(v) {
const s = safeString(v).trim();
if (!s) return true;
const normalized = s.replace(/%/g, '').trim();
if (!normalized) return true;
if (/^[-+]?0+(\.0+)?$/.test(normalized)) return true;
return false;
}
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/knowledge-world/Knowledge-World-项目-Practice-Google-Ads-ads-data-script-01_ev-a84cb9.txt(仅本地保留,不入库不部署)