Google Ads 账户里的改动,并不都是你自己做的。同事、代理商、实习生、自动规则、第三方工具,以及 Google 自己的「自动应用建议」,都可能在你不知道的时候改预算、换出价策略、打开展示广告网络。等到某天发现花费翻倍、转化腰斩,回头翻「更改历史记录」时,往往已经浪费了好几天的预算。
这篇教程介绍一个免费的 Google Ads 脚本:每小时自动检查更改历史记录,把陌生人做的改动、Google 自动应用的建议、以及预算/出价/状态这类高风险改动找出来,翻译成看得懂的中文写进表格,并发邮件提醒。脚本是在 Nils Rooijmans 的 Change History Alerts 脚本基础上重新编写的中文升级版,复制粘贴就能用。
为什么需要改动提醒
- 多人管理的账户:客户自己、代理商、老板都有权限时,谁改了什么很难说清楚。
- Google 的自动应用建议:如果开启过「自动应用」,Google 可能会自动调整目标 CPA、添加关键字、改成广泛匹配,很多人根本不记得自己开过。
- Google 系统改动:例如长期没有展示的广告组会被系统自动暂停,更改记录里显示为「Google 内部工具」。
- 自动规则和第三方工具:规则写错一个条件,可能一夜之间暂停全部广告系列。
- 后台的更改历史记录要自己去看:没人会每天打开翻一遍。
原版脚本做了什么,有哪些不足
原版脚本由 Nils Rooijmans 发布在他的网站上(原文链接),作者介绍这是他内部工具的精简免费版。它每天读取一次前一天的更改记录,排除你在名单里填写的用户,把剩下的改动写进 Google 表格,并发一封邮件提醒。思路简单实用,感谢作者的分享。
在实际使用和读代码时,我们发现这些可以改进的地方:
| 问题 | 影响 |
|---|---|
| 只按用户筛选,不看改动内容 | 自己人误把预算从 100 改成 1000 不会提醒;陌生人改一个关键字出价也会提醒,重要的反而被淹没 |
| 改动前后的值原样写入(技术格式) | 看到的是一串字段名和数据,金额还是 micros 单位(实际金额 × 100 万),很难读懂 |
| 只检查「昨天」,每天运行一次 | 改动最晚要第二天才知道;如果某天脚本没运行,那天的改动就漏掉了 |
| 表头从作者自己托管的一个外部表格复制 | 外部表格不可访问时脚本直接报错 |
| 邮件正文模板在设置期间之前就引用了它 | 邮件里的期间可能显示不正确 |
| 查询失败时只记日志,之后继续使用未定义的结果 | 会导致后续步骤报错 |
| 不区分改动来源 | 自动规则、脚本、编辑器、Google 自动应用的建议混在一起 |
| 只支持单个账户 | 代理商或多账户需要每个账户分别安装 |
| 没有汇总 | 看不出谁改得最多、哪类改动最频繁 |
升级版 v2 改了什么
| 项目 | 原版 | 升级版 v2 |
|---|---|---|
| 检查频率 | 每天一次,只看昨天 | 建议每小时一次,每次回看 48 小时,自动去重,同一条改动只提醒一次 |
| 提醒条件 | 不在名单里的用户 | 不在信任名单里的用户、任何人的高风险改动、Google 自动应用的建议(三项可分别开关) |
| 信任名单 | 逐个填写邮箱 | 可以按邮箱,也可以按域名(例如整个代理商团队) |
| 风险等级 | 无 | 高、中、低三级,并写明原因 |
| 改动内容 | 原始技术格式 | 中文说明,例如「预算:100.00 → 300.00(+200%)」「状态:已启用 → 已暂停」 |
| 对象名称 | 只有广告系列和广告组 | 补充关键字文字、否定关键字、地理位置名称、预算名称 |
| 改动来源 | 原始代码 | 翻译成网页版后台、编辑器、自动规则、脚本、API、自动应用的建议等 |
| 表格 | 依赖外部表头 | 自动新建,自动建好所有工作表 |
| 历史 | 无 | 第一次运行导入过去 30 天的记录,之后持续累积 |
| 汇总 | 无 | 按用户、来源、对象类型、账户统计,附每日改动数图表 |
| 邮件 | 只有条数和链接 | 表格形式列出每条改动,高风险排在前面 |
| 经理账户 | 不支持 | 支持在 MCC 里运行,一次检查最多 50 个账户 |
这个脚本只读取数据,不会修改账户里的任何东西。 它不会撤销改动,只负责告诉你发生了什么。
哪些改动算高风险
默认的判断规则如下,阈值都可以在设置里调整:
| 等级 | 改动 |
|---|---|
| 高 | 预算变化超过 ±20% |
| 高 | 更换出价策略;目标每次转化费用或目标 ROAS 变化超过 ±15% |
| 高 | 广告系列被暂停、启用或移除 |
| 高 | 投放网络改变(例如打开了展示广告网络或搜索网络合作伙伴) |
| 高 | 添加、移除或修改地理位置和语言定位;地理位置选项改变 |
| 高 | 购物设置(Merchant Center 账户、Feed 标签)改变 |
| 高 | Google 自动应用的建议、Google 内部工具做的任何改动 |
| 中 | 不在信任名单里的用户做的任何改动 |
| 中 | 关键字或广告组出价变化超过 ±50% |
| 中 | 最终到达网址、跟踪模板、网址后缀改变 |
| 中 | 移除否定关键字;广告组暂停或移除;新建广告系列 |
| 低 | 信任用户的日常改动(只记录,不提醒) |
安装步骤(10 分钟)
- 登录 Google Ads,点击左侧「工具」→「批量操作」→「脚本」。如果要一次检查多个账户,请在经理账户(MCC)里操作。
- 点击蓝色「+」按钮,选择「新建脚本」,命名为「账户变更提醒」。
- 删掉编辑器里默认的代码,把本文末尾的完整代码全部粘贴进去。
- 修改代码开头的设置:至少填写
EMAIL(接收提醒的邮箱)和TRUSTED_USERS或TRUSTED_DOMAINS(你自己和团队的邮箱)。 - 点击「授权」,用有账户权限的 Google 账号登录并允许访问。
- 点击「预览」。第一次运行会新建一个 Google 表格,并导入过去 30 天的改动(这次不发邮件)。在下方「日志」里找到以「★ 新建的表格」开头的一行,复制表格网址。
- 把网址粘贴到
SPREADSHEET_URL的两个引号中间,保存。 - 回到脚本列表,把运行频率设为「每小时」。不想收太多邮件的话,也可以设为每天一次,因为每次都会回看 48 小时,不会漏掉。
第一次导入的 30 天记录是很好的「账户体检」:打开表格的「汇总」页,看看过去一个月都是谁在改账户、Google 自动改了多少东西,常常会有意外发现。
设置说明
| 设置 | 默认值 | 说明 |
|---|---|---|
| SPREADSHEET_URL | 空 | 报告表格网址,第一次运行后填入 |
| 空 | 接收提醒的邮箱,多个用英文逗号分隔;留空只写表格 | |
| TRUSTED_USERS | 空 | 信任的用户邮箱,他们的日常改动只记录不提醒 |
| TRUSTED_DOMAINS | 空 | 信任的邮箱域名,例如 @youragency.com |
| IGNORE_CLIENT_TYPES | 空 | 完全忽略的改动来源,例如自己写的自动规则 GOOGLE_ADS_AUTOMATED_RULE、脚本 GOOGLE_ADS_SCRIPTS |
| ALERT_UNTRUSTED_USERS | true | 提醒不在信任名单里的人做的改动 |
| ALERT_HIGH_RISK_ANY_USER | true | 提醒任何人(包括信任的人)做的高风险改动 |
| ALERT_AUTO_APPLIED | true | 提醒 Google 自动应用的建议和 Google 内部工具做的改动 |
| BUDGET_CHANGE_PCT | 20 | 预算变化超过这个百分比算高风险 |
| TARGET_CHANGE_PCT | 15 | 目标 CPA / ROAS 变化超过这个百分比算高风险 |
| BID_CHANGE_PCT | 50 | 出价变化超过这个百分比算中风险 |
| LOOKBACK_HOURS | 48 | 每次检查过去多少小时;更改记录有时会延迟出现,建议不低于 24 |
| FIRST_RUN_DAYS | 30 | 第一次运行导入多少天的历史(最多 30) |
| MAX_LOG_ROWS | 50000 | 变更记录最多保留的行数 |
| EMAIL_MAX_ROWS | 50 | 每封邮件最多列出的改动条数 |
| MCC_ACCOUNT_IDS | 空 | 在经理账户里运行时要检查的账户,留空检查下属全部账户(最多 50 个) |
如果你自己写了自动规则或者用了自己的脚本,又不想收到它们的提醒,把对应来源加到 IGNORE_CLIENT_TYPES 里即可。原版脚本建议把用户「Bulk Actions」加入忽略名单来排除自动规则,升级版改为按来源类型排除,更可靠。
怎么看报告
本次提醒
每次运行新发现、符合提醒条件的改动,高风险排在最前面。每一行有改动人、来源、对象、改动前后的值、风险原因和处理建议。邮件里的内容和这张表一致。
变更记录
所有改动的完整记录,包括信任用户的日常改动。可以按用户、来源、广告系列筛选,相当于一份不会过期的更改历史。最后一列「唯一键」用于去重,默认隐藏。
汇总
过去 30 天的统计:
- 按用户:谁改得最多,谁做了高风险改动。出现不认识的邮箱时,要确认这个人是否还应该有账户权限。
- 按来源:如果「自动应用的建议」很多,建议到「建议」→「自动应用」里检查开启了哪些类型。
- 按对象类型:改动集中在预算、出价还是关键字。
- 每天的改动数:配合效果数据看,某天花费异常时先看那天改了什么。
运行日志
每一步是否成功。某一部分出错时不会影响其他部分,错误会写在这里。
收到提醒后怎么处理
- 先确认是不是计划内的:问一下团队或客户。预算和出价策略的调整很可能就是老板临时决定的。
- 不是计划内的,马上改回去:在 Google Ads 的「更改历史记录」里找到对应改动,可以直接点「撤消」。
- 来源是自动应用的建议:到「建议」→「自动应用」,关闭不需要的类型。常见需要关闭的有:添加新关键字、使用广泛匹配、调整目标 CPA/ROAS、删除重复或冗余关键字。
- 来源是陌生用户:到「管理」→「访问权限和安全」检查用户列表,移除不再需要权限的人。
- 出价策略或目标改动:改回去之后,出价策略会重新进入学习期。之后一两周尽量不要再大幅调整,让数据稳定下来。
常见问题
为什么有些改动没有记录?
Google 的更改记录接口不包含所有改动。例如转化操作设置、受众列表、部分账户层级设置、效果最大化广告系列里的一些素材改动,可能不在里面。另外,脚本通过接口只能查询最近 30 天的记录(后台页面能看到更久),所以建议持续运行,在表格里积累历史。
为什么对象显示为一串 ID?
更改记录里只包含被修改的字段。比如改了关键字出价,记录里只有出价,没有关键字文字。脚本会额外查询关键字、定位和预算的名称来补充;如果对象已经被删除,或者查询失败,就会显示 ID。
每小时运行会不会收到太多邮件?
只有发现新的、符合条件的改动时才发邮件,没有新改动就不发。如果团队每天改动很多,把团队域名加进 TRUSTED_DOMAINS,就只会收到高风险改动和陌生人改动的提醒。
一天改动超过 1 万条怎么办?
Google 每次查询最多返回 1 万条改动,脚本按天分段查询。一天内超过 1 万条(通常是批量上传或 API 工具)时,日志里会提示超出部分没有读取。这种情况建议把对应来源加到忽略名单。
经理账户里能检查多少个账户?
脚本按顺序检查,最多 50 个账户,单次运行最长 30 分钟。账户很多时,用 MCC_ACCOUNT_IDS 指定重点账户,或者分成几个脚本运行。
和 Google Ads 自带的提醒有什么区别?
Google Ads 后台可以设置部分账户通知,但不会按改动内容判断风险,也不会把改动前后的值整理成报告。这个脚本更像一个专门盯着更改历史的助手。
有没有现成的付费工具?
管理很多账户的代理商可以考虑 Optmyzr 这类工具,自带账户改动监控和多账户报告。原版作者 Nils Rooijmans 也提供功能更完整的付费版本。只有几个账户的话,这个免费脚本已经够用。
搭配使用的其他脚本
- Google Ads 质量得分追踪脚本:每天记录质量得分和三个组成部分,找出掉分的关键字。
- PMax 健康检查脚本:检查效果最大化广告系列的渠道、素材和商品表现。
- 更多免费工具:毕瑞出海工具箱。
完整代码
点击代码框右上角可以复制全部代码。复制后粘贴到 Google Ads 脚本编辑器,修改开头的设置即可。
代码也放在 GitHub 上,可以直接下载:change-alerts-v2.js。复制时点击代码框右上角的按钮,或者全选后复制。
/**
* 账户变更提醒脚本 v2.0(毕瑞出海中文版)
* ------------------------------------------------------------
* 作用:定时检查 Google Ads 的「更改历史记录」,把不在信任名单里的人、
* Google 自动应用的建议、以及预算/出价/状态等高风险改动找出来,
* 写进 Google 表格并发邮件提醒。改动前后的数值会翻译成看得懂的中文,
* 例如「预算:100.00 → 300.00(+200%)」。
*
* 思路参考 Nils Rooijmans 的 Change History Alerts 脚本,代码为重新编写:
* - 不依赖外部表头表格,自动建好所有工作表;
* - 可以每小时运行,自动去重,同一条改动只提醒一次;
* - 按改动内容判断风险等级,不只是按改动人筛选;
* - 解析改动前后的值(预算、出价、目标 CPA/ROAS、状态、网址等);
* - 按改动来源(网页版、编辑器、自动规则、自动应用的建议、脚本、API)分类;
* - 支持在经理账户(MCC)里运行,一次检查多个账户。
*
* 使用方法和每个工作表的解读:https://berightdigital.com/google-ads-change-history-alerts/
* ------------------------------------------------------------
*/
// ======================= 设置(只需要改这里) =======================
var CONFIG = {
// Google 表格网址。留空 = 第一次运行时自动新建,网址打印在「日志」里,复制回这里。
SPREADSHEET_URL: '',
// 接收提醒的邮箱,多个用英文逗号分隔。留空 = 只写表格,不发邮件。
EMAIL: '',
// 信任的用户:这些邮箱做的改动只记录,不单独提醒(高风险改动除外,见下方设置)
TRUSTED_USERS: [
// '[email protected]',
],
// 信任的邮箱域名:例如 '@youragency.com',整个团队的改动都算信任
TRUSTED_DOMAINS: [
// '@youragency.com',
],
// 完全忽略的改动来源(不记录也不提醒)。可选值见下方 CLIENT_CN 列表,例如:
// 'GOOGLE_ADS_AUTOMATED_RULE'(自动规则)、'GOOGLE_ADS_SCRIPTS'(脚本)
IGNORE_CLIENT_TYPES: [],
// 提醒哪些改动(满足任意一条就提醒)
ALERT_UNTRUSTED_USERS: true, // 不在信任名单里的人做的改动
ALERT_HIGH_RISK_ANY_USER: true, // 任何人做的高风险改动(包括信任的人)
ALERT_AUTO_APPLIED: true, // Google 自动应用的建议、Google 内部工具做的改动
// 高风险判断标准
BUDGET_CHANGE_PCT: 20, // 预算变化超过 ±20% 算高风险
TARGET_CHANGE_PCT: 15, // 目标 CPA / 目标 ROAS 变化超过 ±15% 算高风险
BID_CHANGE_PCT: 50, // 关键字或广告组出价变化超过 ±50% 算中风险
// 每次检查过去多少小时(更改记录有时会延迟出现,建议不低于 24)
LOOKBACK_HOURS: 48,
// 第一次运行时导入过去多少天的记录(接口最多只能查 30 天),这部分不发邮件
FIRST_RUN_DAYS: 30,
// 「变更记录」最多保留多少行,超过的删除最旧的记录
MAX_LOG_ROWS: 50000,
// 每封邮件最多列出多少条改动
EMAIL_MAX_ROWS: 50,
// 只在经理账户(MCC)里运行时有效:要检查的账户 ID(留空 = 下属全部账户,最多 50 个)
MCC_ACCOUNT_IDS: [
// '123-456-7890',
]
};
// ======================= 设置结束,下面的代码一般不需要改 =======================
var VERSION = 'v2.0';
var RUN_LOG = [];
var STARTED = new Date();
var TIME_BUDGET_MS = 25 * 60 * 1000; // 脚本最多运行 30 分钟,留 5 分钟余量
var LOG_HEADER = ['变更时间', '账户', '风险', '风险原因', '用户', '来源', '对象类型', '操作', '广告系列', '广告组',
'对象', '改动内容', '改动字段', '是否提醒', '记录时间', '唯一键'];
var COL = {}; LOG_HEADER.forEach(function (h, i) { COL[h] = i; });
var CLIENT_CN = {
GOOGLE_ADS_WEB_CLIENT: '网页版后台',
GOOGLE_ADS_EDITOR: 'Google Ads 编辑器',
GOOGLE_ADS_MOBILE_APP: '手机 App',
GOOGLE_ADS_AUTOMATED_RULE: '自动规则',
GOOGLE_ADS_SCRIPTS: '脚本',
GOOGLE_ADS_BULK_UPLOAD: '批量上传',
GOOGLE_ADS_API: 'API(第三方工具)',
GOOGLE_ADS_RECOMMENDATIONS: '建议页面(手动应用)',
GOOGLE_ADS_RECOMMENDATIONS_SUBSCRIPTION: '自动应用的建议',
SEARCH_ADS_360_SYNC: 'SA360 同步',
SEARCH_ADS_360_POST: 'SA360',
INTERNAL_TOOL: 'Google 内部工具',
OTHER: '其他',
UNKNOWN: '未知', UNSPECIFIED: '未知'
};
var AUTO_CLIENTS = { GOOGLE_ADS_RECOMMENDATIONS_SUBSCRIPTION: 1, INTERNAL_TOOL: 1 };
var TYPE_CN = {
CAMPAIGN: '广告系列', CAMPAIGN_BUDGET: '预算', AD_GROUP: '广告组', AD_GROUP_AD: '广告', AD: '广告内容',
AD_GROUP_CRITERION: '关键字/受众(广告组)', CAMPAIGN_CRITERION: '定位/否定(广告系列)',
AD_GROUP_BID_MODIFIER: '出价调整', ASSET: '素材', CUSTOMER_ASSET: '账户素材', CAMPAIGN_ASSET: '广告系列素材',
AD_GROUP_ASSET: '广告组素材', ASSET_SET: '素材组合', ASSET_SET_ASSET: '素材组合内容', CAMPAIGN_ASSET_SET: '广告系列素材组合',
ASSET_GROUP: '素材资源组', ASSET_GROUP_ASSET: '素材资源组素材',
FEED: 'Feed', FEED_ITEM: 'Feed 项', CAMPAIGN_FEED: '广告系列 Feed', AD_GROUP_FEED: '广告组 Feed'
};
var OP_CN = { CREATE: '新建', UPDATE: '修改', REMOVE: '移除', UNKNOWN: '未知', UNSPECIFIED: '未知' };
var STATUS_CN = { ENABLED: '已启用', PAUSED: '已暂停', REMOVED: '已移除' };
// 常见字段的中文名(按字段路径的最后一段或完整路径匹配)
var FIELD_CN = {
'status': '状态', 'name': '名称', 'amountMicros': '预算', 'deliveryMethod': '投放方式',
'biddingStrategyType': '出价策略', 'biddingStrategy': '组合出价策略',
'targetCpa.targetCpaMicros': '目标每次转化费用', 'maximizeConversions.targetCpaMicros': '目标每次转化费用',
'targetRoas.targetRoas': '目标广告支出回报率', 'maximizeConversionValue.targetRoas': '目标广告支出回报率',
'manualCpc.enhancedCpcEnabled': '智能点击付费', 'targetImpressionShare.location': '目标展示位置',
'targetImpressionShare.locationFractionMicros': '目标展示份额', 'targetImpressionShare.cpcBidCeilingMicros': '出价上限',
'cpcBidMicros': '每次点击费用出价', 'cpmBidMicros': '每千次展示出价', 'cpvBidMicros': '每次观看出价',
'targetCpaMicros': '目标每次转化费用', 'targetRoas': '目标广告支出回报率',
'effectiveCpcBidMicros': '有效出价', 'bidModifier': '出价调整',
'finalUrls': '最终到达网址', 'finalMobileUrls': '手机版最终到达网址', 'trackingUrlTemplate': '跟踪模板',
'finalUrlSuffix': '最终网址后缀', 'urlCustomParameters': '自定义参数',
'networkSettings.targetGoogleSearch': 'Google 搜索', 'networkSettings.targetSearchNetwork': '搜索网络合作伙伴',
'networkSettings.targetContentNetwork': '展示广告网络', 'networkSettings.targetPartnerSearchNetwork': '合作伙伴网络',
'startDate': '开始日期', 'endDate': '结束日期', 'startDateTime': '开始时间', 'endDateTime': '结束时间',
'keyword.text': '关键字', 'keyword.matchType': '匹配方式', 'negative': '否定',
'location.geoTargetConstant': '地理位置', 'language.languageConstant': '语言', 'proximity.radius': '半径',
'geoTargetTypeSetting.positiveGeoTargetType': '地理位置选项', 'geoTargetTypeSetting.negativeGeoTargetType': '排除地理位置选项',
'ad.responsiveSearchAd.headlines': '标题', 'ad.responsiveSearchAd.descriptions': '说明',
'ad.responsiveSearchAd.path1': '路径 1', 'ad.responsiveSearchAd.path2': '路径 2',
'ad.finalUrls': '最终到达网址', 'responsiveSearchAd.headlines': '标题', 'responsiveSearchAd.descriptions': '说明',
'adSchedule.dayOfWeek': '投放日', 'device.type': '设备', 'shoppingSetting.merchantId': 'Merchant Center 账户',
'shoppingSetting.feedLabel': 'Feed 标签', 'optimizationGoalSetting.optimizationGoalTypes': '优化目标',
'urlExpansionOptOut': '网址扩展', 'assetAutomationSettings': '素材自动化设置',
'textAsset.text': '文字', 'sitelinkAsset.linkText': '附加链接文字', 'calloutAsset.calloutText': '宣传信息',
'type': '类型', 'fieldType': '素材用途'
};
// 不显示的技术字段
var SKIP_FIELDS = /^(resourceName|id|campaign|adGroup|baseCampaign|labels|servingStatus|primaryStatus|primaryStatusReasons|effective.*|criterionId|systemServingStatus|approvalStatus|policySummary.*)$/;
function main() {
var ss = openSpreadsheet();
var logSh = ensureSheet(ss, '变更记录', LOG_HEADER);
var existing = readLog(logSh);
var firstRun = existing.rows.length === 0 && !readState(ss).lastRun;
log('信息', '开始', '版本 ' + VERSION + (firstRun ? ',第一次运行,导入过去 ' + CONFIG.FIRST_RUN_DAYS + ' 天的记录' : ''));
var accounts = listAccounts();
var fresh = [];
accounts.forEach(function (acc) {
if (new Date() - STARTED > TIME_BUDGET_MS) { log('提示', '时间', '接近 30 分钟上限,跳过账户 ' + acc.label); return; }
var got = safe('读取 ' + acc.label, function () { return processAccount(acc, existing.keys, firstRun); }) || [];
fresh = fresh.concat(got);
});
fresh.sort(function (a, b) { return a.time < b.time ? -1 : a.time > b.time ? 1 : 0; });
var alerts = fresh.filter(function (c) { return c.alert; });
log('信息', '结果', '新增改动 ' + fresh.length + ' 条,需要提醒 ' + alerts.length + ' 条(高风险 ' +
alerts.filter(function (c) { return c.risk === '高'; }).length + ' 条)');
safe('写入变更记录', function () { appendLog(logSh, fresh); });
safe('提醒', function () { writeAlerts(ss, alerts, firstRun); });
safe('汇总', function () { writeSummary(ss, logSh); });
safe('使用说明', function () { writeGuide(ss, accounts); });
writeState(ss);
if (CONFIG.EMAIL && alerts.length && !firstRun) safe('邮件', function () { sendEmail(ss, alerts, accounts); });
if (firstRun && alerts.length) log('提示', '邮件', '第一次运行导入的是历史记录,不发邮件。之后的新改动会正常提醒。');
log('信息', '完成', '用时 ' + Math.round((new Date() - STARTED) / 1000) + ' 秒,表格:' + ss.getUrl());
writeLog(ss);
orderSheets(ss);
}
// ======================= 账户 =======================
// 在经理账户里运行时,逐个切换到下属账户;在普通账户里运行时,只检查当前账户
function listAccounts() {
var isMcc = false;
try { isMcc = typeof AdsManagerApp !== 'undefined' && !!AdsManagerApp.accounts; } catch (e) { isMcc = false; }
if (isMcc) {
try {
var sel = AdsManagerApp.accounts();
if (CONFIG.MCC_ACCOUNT_IDS.length) sel = sel.withIds(CONFIG.MCC_ACCOUNT_IDS);
var it = sel.withLimit(50).get(), out = [];
while (it.hasNext()) {
var a = it.next();
out.push({ obj: a, id: a.getCustomerId(), name: a.getName(), label: a.getName() + '(' + a.getCustomerId() + ')' });
}
if (out.length) { log('信息', '账户', '经理账户模式,检查 ' + out.length + ' 个账户'); return out; }
} catch (e) {
log('提示', '账户', '无法读取下属账户,只检查当前账户:' + e.message);
}
}
var cur = AdsApp.currentAccount();
return [{ obj: null, id: cur.getCustomerId(), name: cur.getName(), label: cur.getName() + '(' + cur.getCustomerId() + ')' }];
}
function processAccount(acc, seenKeys, firstRun) {
if (acc.obj) AdsManagerApp.select(acc.obj);
var tz = AdsApp.currentAccount().getTimeZone();
// Google 只允许查询最近 30 天内的记录,留 1 小时余量
var hours = Math.min(firstRun ? CONFIG.FIRST_RUN_DAYS * 24 : CONFIG.LOOKBACK_HOURS, 30 * 24 - 1);
var rows = fetchChanges(tz, hours, acc.label);
var out = [];
rows.forEach(function (r) {
var c = safe('解析改动', function () { return parseChange(r, acc, tz); });
if (!c || c.ignored) return;
if (seenKeys[c.key]) return;
seenKeys[c.key] = 1;
out.push(c);
});
safe('补充对象名称', function () { enrich(out); });
out.forEach(function (c) { safe('判断风险', function () { finalize(c); }); });
log('信息', acc.label, '读取 ' + rows.length + ' 条,新增 ' + out.length + ' 条');
return out;
}
// ======================= 读取更改记录 =======================
// change_event 每次查询最多返回 10000 条,所以按天分段查询
function fetchChanges(tz, hours, label) {
var fields = ['change_event.change_date_time', 'change_event.user_email', 'change_event.client_type',
'change_event.change_resource_type', 'change_event.change_resource_name', 'change_event.resource_change_operation',
'change_event.changed_fields', 'change_event.old_resource', 'change_event.new_resource',
'campaign.name', 'ad_group.name'];
var end = new Date(), all = [], DAY = 864e5;
var start = new Date(end.getTime() - hours * 36e5);
for (var from = start; from < end; from = new Date(from.getTime() + DAY)) {
var to = new Date(Math.min(from.getTime() + DAY, end.getTime()));
var q = 'SELECT ' + fields.join(', ') + ' FROM change_event' +
' WHERE change_event.change_date_time >= "' + fmt(from, tz) + '"' +
' AND change_event.change_date_time <= "' + fmt(to, tz) + '"' +
' ORDER BY change_event.change_date_time ASC LIMIT 10000';
var part;
try {
part = runQuery(q);
} catch (e) {
// 部分账户不能同时选择 campaign / ad_group 名称,去掉后重试
log('提示', label, '查询失败,去掉广告系列/广告组名称后重试:' + e.message);
part = runQuery(q.replace(', campaign.name, ad_group.name', ''));
}
if (part.length >= 10000) log('提示', label, fmt(from, tz) + ' 起的一天内超过 10000 条改动,超出部分没有读取');
all = all.concat(part);
}
return all;
}
function fmt(d, tz) { return Utilities.formatDate(d, tz, 'yyyy-MM-dd HH:mm:ss'); }
// ======================= 解析一条改动 =======================
function parseChange(r, acc, tz) {
var ce = r.changeEvent || {};
var client = String(ce.clientType || 'UNKNOWN');
var type = String(ce.changeResourceType || 'UNKNOWN');
var op = String(ce.resourceChangeOperation || 'UNKNOWN');
var user = String(ce.userEmail || '');
var time = String(ce.changeDateTime || '').slice(0, 19);
var c = {
time: time, account: acc.label, user: user, client: client, type: type, op: op,
campaign: g(r, 'campaign.name'), group: g(r, 'adGroup.name'),
key: acc.id + '|' + ce.changeResourceName + '|' + ce.changeDateTime + '|' + op,
ignored: CONFIG.IGNORE_CLIENT_TYPES.indexOf(client) >= 0
};
if (c.ignored) return c;
// 注意:old_resource / new_resource 里只有被修改的字段,名称等信息要另外查询(见 enrich)
c.oldR = unwrap(ce.oldResource); c.newR = unwrap(ce.newResource);
c.resName = String(ce.changeResourceName || '');
c.paths = changedPaths(ce.changedFields);
return c;
}
// 新建对象时,关键字文字、地理位置等已经显示在「对象」里,说明里不再重复
function createDiffs(diffs) {
return diffs.filter(function (d) { return !/^(keyword\.|location\.|language\.|negative$|type$)/.test(d.path); });
}
// 第二步:补充名称后,生成改动说明并判断风险
function finalize(c) {
var oldR = c.oldR, newR = c.newR, paths = c.paths, op = c.op, type = c.type;
var diffs = [];
paths.forEach(function (p) {
var last = p.split('.').pop();
if (SKIP_FIELDS.test(p) || SKIP_FIELDS.test(last)) return;
var ov = getPath(oldR, p), nv = getPath(newR, p);
diffs.push({ path: p, name: fieldName(p), oldV: ov, newV: nv, text: fieldName(p) + ':' + show(p, ov) + ' → ' + show(p, nv) + pctText(p, ov, nv) });
});
c.diffs = diffs;
c.fields = paths.join(', ');
c.object = objectLabel(c);
c.detail = op === 'CREATE' ? '新建' + (c.object ? ':' + c.object : '') + (createDiffs(diffs).length ? '(' + createDiffs(diffs).slice(0, 4).map(function (d) { return d.name + ' ' + show(d.path, d.newV); }).join(',') + ')' : '')
: op === 'REMOVE' ? '移除' + (c.object ? ':' + c.object : '')
: diffs.map(function (d) { return d.text; }).join(';') || '(Google 没有返回具体字段)';
var rk = assessRisk(c);
c.risk = rk.level; c.reason = rk.reasons.join(';');
c.trusted = isTrusted(c.user);
c.auto = !!AUTO_CLIENTS[c.client];
c.alert = (CONFIG.ALERT_UNTRUSTED_USERS && !c.trusted && !c.auto) ||
(CONFIG.ALERT_HIGH_RISK_ANY_USER && c.risk === '高') ||
(CONFIG.ALERT_AUTO_APPLIED && c.auto);
if (!c.trusted && !c.auto && c.risk === '低') { c.risk = '中'; c.reason = (c.reason ? c.reason + ';' : '') + '不在信任名单里的用户'; }
delete c.oldR; delete c.newR;
return c;
}
// old_resource / new_resource 是 { campaign: {...} } 这样的结构,取出里面那一层
function unwrap(res) {
if (!res || typeof res !== 'object') return {};
var keys = Object.keys(res).filter(function (k) { return res[k] && typeof res[k] === 'object'; });
return keys.length ? res[keys[0]] : res;
}
// changed_fields 可能是 "status,amount_micros" 这样的文字,也可能是 { paths: [...] }
function changedPaths(f) {
var list = [];
if (!f) return list;
if (typeof f === 'string') list = f.split(',');
else if (f.paths) list = f.paths;
else if (Array.isArray(f)) list = f;
return list.map(function (p) { return camel(String(p).trim()); }).filter(function (p) { return p; });
}
function camel(p) { return p.replace(/_([a-z0-9])/g, function (_, c) { return c.toUpperCase(); }); }
function getPath(o, p) {
var parts = p.split('.'), cur = o;
for (var i = 0; i < parts.length; i++) { if (cur === null || cur === undefined) return undefined; cur = cur[parts[i]]; }
return cur;
}
function fieldName(p) {
if (FIELD_CN[p]) return FIELD_CN[p];
var parts = p.split('.');
for (var i = 1; i < parts.length; i++) { var sub = parts.slice(i).join('.'); if (FIELD_CN[sub]) return FIELD_CN[sub]; }
return p;
}
function isMicros(p) { return /Micros$/.test(p) && !/FractionMicros$/.test(p); }
function show(p, v) {
if (v === undefined || v === null || v === '') return '(空)';
if (isMicros(p)) return (Number(v) / 1e6).toFixed(2);
if (/FractionMicros$/.test(p)) return (Number(v) / 1e4).toFixed(1) + '%';
if (/targetRoas$/.test(p)) return Math.round(Number(v) * 100) + '%';
if (/bidModifier$/.test(p)) return (Number(v) === 0 ? '排除' : (Math.round((Number(v) - 1) * 100) >= 0 ? '+' : '') + Math.round((Number(v) - 1) * 100) + '%');
if (/(^|\.)status$/.test(p) && STATUS_CN[v]) return STATUS_CN[v];
if (/geoTargetConstant$/.test(p)) return geoName(v);
if (typeof v === 'boolean') return v ? '开' : '关';
if (Array.isArray(v)) return trim(v.map(function (x) { return x && typeof x === 'object' ? (x.text || x.asset || JSON.stringify(x)) : String(x); }).join(' | '), 120);
if (typeof v === 'object') return trim(JSON.stringify(v), 120);
return trim(String(v), 120);
}
function trim(s, n) { return s.length > n ? s.slice(0, n) + '…' : s; }
function pctOf(ov, nv) {
var a = Number(ov), b = Number(nv);
if (!(a > 0) || isNaN(b)) return null;
return (b - a) / a * 100;
}
function pctText(p, ov, nv) {
if (!(isMicros(p) || /targetRoas$/.test(p))) return '';
var x = pctOf(ov, nv);
return x === null ? '' : '(' + (x >= 0 ? '+' : '') + Math.round(x) + '%)';
}
function objectLabel(c) {
// 合并:查询到的当前信息 + 改动前 + 改动后
var res = merge(merge(merge({}, c.lookup || {}), c.oldR || {}), c.newR || {});
if (c.type === 'CAMPAIGN') return c.campaign || res.name || shortName(c.resName);
if (c.type === 'AD_GROUP') return c.group || res.name || shortName(c.resName);
if (res.keyword && res.keyword.text) return res.keyword.text + '(' + ({ EXACT: '完全', PHRASE: '词组', BROAD: '广泛' }[res.keyword.matchType] || res.keyword.matchType || '') + ')' + (res.negative ? ' [否定]' : '');
if (res.location && res.location.geoTargetConstant) return '地理位置 ' + geoName(res.location.geoTargetConstant) + (res.negative ? ' [排除]' : '');
if (res.language && res.language.languageConstant) return '语言 ' + String(res.language.languageConstant).split('/').pop();
if (res.ad) return '广告 ' + (res.ad.id || shortName(c.resName).split('~').pop()) + (res.ad.type ? '(' + res.ad.type + ')' : '');
if (res.name) return String(res.name);
if (res.textAsset && res.textAsset.text) return trim(res.textAsset.text, 60);
if (res.sitelinkAsset && res.sitelinkAsset.linkText) return '附加链接:' + res.sitelinkAsset.linkText;
if (res.type) return String(res.type);
return shortName(c.resName);
}
function shortName(rn) { return rn ? String(rn).split('/').slice(-2).join('/') : ''; }
function merge(a, b) {
Object.keys(b || {}).forEach(function (k) {
var v = b[k];
if (v && typeof v === 'object' && !Array.isArray(v)) a[k] = merge(a[k] && typeof a[k] === 'object' ? a[k] : {}, v);
else if (v !== undefined && v !== null && v !== '') a[k] = v;
});
return a;
}
// ======================= 补充名称 =======================
// old/new_resource 里只有被修改的字段,例如改了关键字出价,里面就只有出价,没有关键字文字。
// 这里按资源名称批量查询关键字、定位、预算的名称,以及地理位置的中文/英文名。
var GEO_NAMES = {};
function enrich(list) {
var groups = {
AD_GROUP_CRITERION: { res: 'ad_group_criterion', key: 'adGroupCriterion',
fields: 'ad_group_criterion.resource_name, ad_group_criterion.type, ad_group_criterion.keyword.text, ad_group_criterion.keyword.match_type, ad_group_criterion.negative' },
CAMPAIGN_CRITERION: { res: 'campaign_criterion', key: 'campaignCriterion',
fields: 'campaign_criterion.resource_name, campaign_criterion.type, campaign_criterion.keyword.text, campaign_criterion.keyword.match_type, campaign_criterion.negative, campaign_criterion.location.geo_target_constant, campaign_criterion.language.language_constant' },
CAMPAIGN_BUDGET: { res: 'campaign_budget', key: 'campaignBudget', fields: 'campaign_budget.resource_name, campaign_budget.name, campaign_budget.amount_micros' }
};
Object.keys(groups).forEach(function (type) {
var gdef = groups[type], byName = {};
list.forEach(function (c) { if (c.type === type && c.resName) (byName[c.resName] = byName[c.resName] || []).push(c); });
var names = Object.keys(byName);
for (var i = 0; i < names.length; i += 300) {
var chunk = names.slice(i, i + 300);
try {
runQuery('SELECT ' + gdef.fields + ' FROM ' + gdef.res + ' WHERE ' + gdef.res + '.resource_name IN ("' + chunk.join('","') + '")')
.forEach(function (r) {
var obj = r[gdef.key] || {};
(byName[obj.resourceName] || []).forEach(function (c) { c.lookup = obj; });
});
} catch (e) { log('提示', '补充名称', gdef.res + ' 查询失败(不影响提醒,只是对象名称会显示为 ID):' + e.message); }
}
});
// 地理位置名称
var geos = {};
list.forEach(function (c) {
[c.lookup, c.oldR, c.newR].forEach(function (o) { if (o && o.location && o.location.geoTargetConstant) geos[o.location.geoTargetConstant] = 1; });
});
var ids = Object.keys(geos).filter(function (k) { return !GEO_NAMES[k]; });
if (ids.length) {
try {
runQuery('SELECT geo_target_constant.resource_name, geo_target_constant.canonical_name FROM geo_target_constant WHERE geo_target_constant.resource_name IN ("' + ids.slice(0, 500).join('","') + '")')
.forEach(function (r) { var gt = r.geoTargetConstant || {}; GEO_NAMES[gt.resourceName] = gt.canonicalName; });
} catch (e) { log('提示', '补充名称', '地理位置名称查询失败:' + e.message); }
}
}
function geoName(rn) { return GEO_NAMES[rn] || ('ID ' + String(rn).split('/').pop()); }
// ======================= 风险判断 =======================
function assessRisk(c) {
var lv = 0, reasons = [];
function hit(level, why) { lv = Math.max(lv, level); reasons.push(why); }
if (AUTO_CLIENTS[c.client]) hit(2, CLIENT_CN[c.client] + '做的改动');
if (c.type === 'CAMPAIGN') {
if (c.op === 'REMOVE') hit(2, '移除了广告系列');
if (c.op === 'CREATE') hit(1, '新建了广告系列');
}
if (c.type === 'AD_GROUP' && c.op === 'REMOVE') hit(1, '移除了广告组');
if (c.type === 'CAMPAIGN_BUDGET' && c.op === 'CREATE') hit(1, '新建了预算');
c.diffs.forEach(function (d) {
var p = d.path;
if (/(^|\.)status$/.test(p) && c.type === 'CAMPAIGN') hit(2, '广告系列状态:' + show(p, d.oldV) + ' → ' + show(p, d.newV));
else if (/(^|\.)status$/.test(p) && c.type === 'AD_GROUP') hit(1, '广告组状态改变');
if (p === 'amountMicros') {
var x = pctOf(d.oldV, d.newV);
if (x !== null && Math.abs(x) >= CONFIG.BUDGET_CHANGE_PCT) hit(2, '预算变化 ' + Math.round(x) + '%');
else hit(1, '预算调整');
}
if (/biddingStrategyType$|^biddingStrategy$/.test(p)) hit(2, '更换了出价策略');
if (/targetCpaMicros$|targetRoas$/.test(p)) {
var y = pctOf(d.oldV, d.newV);
if (y === null || Math.abs(y) >= CONFIG.TARGET_CHANGE_PCT) hit(2, fieldName(p) + (y === null ? '改变' : '变化 ' + Math.round(y) + '%'));
else hit(1, fieldName(p) + '小幅调整');
}
if (/cpcBidMicros$|cpmBidMicros$/.test(p)) {
var z = pctOf(d.oldV, d.newV);
if (z !== null && Math.abs(z) >= CONFIG.BID_CHANGE_PCT) hit(1, '出价变化 ' + Math.round(z) + '%');
}
if (/^networkSettings\./.test(p)) hit(2, '投放网络改变:' + fieldName(p) + ' ' + show(p, d.oldV) + ' → ' + show(p, d.newV));
if (/geoTargetTypeSetting/.test(p)) hit(2, '地理位置选项改变');
if (c.op !== 'CREATE' && /finalUrls$|finalMobileUrls$|trackingUrlTemplate$|finalUrlSuffix$/.test(p)) hit(1, fieldName(p) + '改变');
if (/^(endDate|endDateTime)$/.test(p)) hit(1, '结束日期改变');
if (/shoppingSetting/.test(p)) hit(2, '购物设置改变');
if (/urlExpansionOptOut$|assetAutomationSettings/.test(p)) hit(1, '自动化设置改变');
});
if (c.type === 'CAMPAIGN_CRITERION') {
if (/地理位置|语言/.test(c.object)) hit(2, (c.op === 'REMOVE' ? '移除了' : c.op === 'CREATE' ? '添加了' : '修改了') + '定位:' + c.object);
else if (/\[否定\]/.test(c.object) && c.op === 'REMOVE') hit(1, '移除了否定关键字');
}
if (c.type === 'AD_GROUP_CRITERION' && /\[否定\]/.test(c.object) && c.op === 'REMOVE') hit(1, '移除了否定关键字');
if (c.type === 'AD_GROUP_AD' && c.op === 'CREATE' && AUTO_CLIENTS[c.client]) hit(2, 'Google 自动添加了广告');
return { level: lv >= 2 ? '高' : lv === 1 ? '中' : '低', reasons: reasons };
}
function isTrusted(email) {
var e = String(email || '').toLowerCase();
if (!e) return false;
if (CONFIG.TRUSTED_USERS.some(function (u) { return String(u).toLowerCase() === e; })) return true;
return CONFIG.TRUSTED_DOMAINS.some(function (d) { d = String(d).toLowerCase(); return d && e.slice(-d.length) === d; });
}
// ======================= 写入表格 =======================
function readLog(sh) {
var last = sh.getLastRow(), rows = [], keys = {};
if (last > 1) {
rows = sh.getRange(2, 1, last - 1, LOG_HEADER.length).getValues();
rows.forEach(function (r) { keys[String(r[COL['唯一键']])] = 1; });
}
return { rows: rows, keys: keys };
}
function toRow(c, now) {
return [c.time, c.account, c.risk, c.reason, c.user || '(无)', cn(CLIENT_CN, c.client), cn(TYPE_CN, c.type), cn(OP_CN, c.op),
c.campaign, c.group, c.object, c.detail, c.fields, c.alert ? '是' : '', now, c.key];
}
function appendLog(sh, list) {
if (!list.length) return;
var now = Utilities.formatDate(new Date(), AdsApp.currentAccount().getTimeZone(), 'yyyy-MM-dd HH:mm');
var rows = list.map(function (c) { return cleanRow(toRow(c, now)); });
var start = sh.getLastRow() + 1;
sh.getRange(start, 1, rows.length, 1).setNumberFormat('@');
sh.getRange(start, 15, rows.length, 2).setNumberFormat('@');
sh.getRange(start, 1, rows.length, LOG_HEADER.length).setValues(rows);
sh.getRange(start, 3, rows.length, 1).setBackgrounds(rows.map(function (r) { return [riskColor(r[2])]; }));
var over = sh.getLastRow() - 1 - CONFIG.MAX_LOG_ROWS;
if (over > 0) { sh.deleteRows(2, over); log('信息', '变更记录', '超过 ' + CONFIG.MAX_LOG_ROWS + ' 行,删除了最旧的 ' + over + ' 行'); }
sh.hideColumns(COL['唯一键'] + 1);
}
function writeAlerts(ss, alerts, firstRun) {
var rank = { '高': 0, '中': 1, '低': 2 };
var list = alerts.slice().sort(function (a, b) { return rank[a.risk] - rank[b.risk] || (a.time < b.time ? 1 : -1); });
var header = ['风险', '变更时间', '账户', '用户', '来源', '对象类型', '操作', '广告系列', '广告组', '对象', '改动内容', '风险原因', '建议'];
var data = list.map(function (c) {
return [c.risk, c.time, c.account, c.user || '(无)', cn(CLIENT_CN, c.client), cn(TYPE_CN, c.type), cn(OP_CN, c.op),
c.campaign, c.group, c.object, c.detail, c.reason, adviceFor(c)];
});
var sh = ss.getSheetByName('本次提醒') || ss.insertSheet('本次提醒');
sh.clear();
var title = firstRun ? '第一次运行:以下是过去 ' + CONFIG.FIRST_RUN_DAYS + ' 天里符合提醒条件的改动(只写表格,不发邮件)'
: '本次运行新发现的改动(' + Utilities.formatDate(new Date(), AdsApp.currentAccount().getTimeZone(), 'yyyy-MM-dd HH:mm') + ')';
sh.getRange(1, 1).setValue(title).setFontWeight('bold');
sh.getRange(2, 1, 1, header.length).setValues([header]).setFontWeight('bold').setBackground('#173a52').setFontColor('#ffffff');
if (data.length) {
sh.getRange(3, 1, data.length, header.length).setValues(data.map(cleanRow));
sh.getRange(3, 1, data.length, 1).setBackgrounds(data.map(function (r) { return [riskColor(r[0])]; }));
sh.getRange(3, 1, data.length, header.length).setWrap(true).setVerticalAlignment('top');
} else {
sh.getRange(3, 1).setValue('没有需要提醒的新改动');
}
sh.setFrozenRows(2);
sh.setColumnWidth(10, 220); sh.setColumnWidth(11, 360); sh.setColumnWidth(12, 240); sh.setColumnWidth(13, 300);
}
function adviceFor(c) {
if (c.client === 'GOOGLE_ADS_RECOMMENDATIONS_SUBSCRIPTION')
return '这是 Google 自动应用的建议。确认是否需要;不需要的话到「建议」→「自动应用」里关闭对应类型';
if (c.client === 'INTERNAL_TOOL') return 'Google 系统做的改动(例如暂停长期无展示的对象),确认是否影响投放';
if (/预算/.test(c.reason)) return '确认预算调整是计划内的,并留意之后几天的花费节奏';
if (/出价策略|目标/.test(c.reason)) return '出价策略或目标改动后会重新进入学习期,1–2 周内避免再次大幅调整';
if (/状态|移除了广告系列/.test(c.reason)) return '确认暂停/启用/移除是计划内的操作';
if (/投放网络|地理位置|定位|购物设置/.test(c.reason)) return '定位和网络设置会直接影响流量来源,确认是计划内的改动';
if (/网址|跟踪/.test(c.reason)) return '打开新网址确认页面能正常访问、跟踪参数正确';
if (!c.trusted) return '确认这个用户是谁、是否应该有账户权限;不需要的话到「访问权限和安全」里移除';
return '确认改动是计划内的';
}
function writeSummary(ss, logSh) {
var v = readLog(logSh).rows;
var tz = AdsApp.currentAccount().getTimeZone();
var since = Utilities.formatDate(new Date(Date.now() - 30 * 864e5), tz, 'yyyy-MM-dd');
v = v.filter(function (r) { return String(r[0]).slice(0, 10) >= since; });
function group(idx) {
var m = {};
v.forEach(function (r) {
var k = String(r[idx] || '(空)');
var x = m[k] || (m[k] = { n: 0, hi: 0, alert: 0, last: '' });
x.n++; if (r[COL['风险']] === '高') x.hi++; if (r[COL['是否提醒']] === '是') x.alert++;
if (String(r[0]) > x.last) x.last = String(r[0]);
});
return Object.keys(m).map(function (k) { return [k, m[k].n, m[k].hi, m[k].alert, m[k].last]; })
.sort(function (a, b) { return b[1] - a[1]; });
}
var sh = ss.getSheetByName('汇总') || ss.insertSheet('汇总');
sh.clear();
sh.getRange(1, 1).setValue('过去 30 天的改动汇总(' + since + ' 起,共 ' + v.length + ' 条)').setFontWeight('bold').setFontSize(13);
var row = 3;
[['按用户', COL['用户']], ['按来源', COL['来源']], ['按对象类型', COL['对象类型']], ['按账户', COL['账户']]].forEach(function (s) {
var data = group(s[1]);
var head = [[s[0], '改动数', '高风险', '已提醒', '最近一次']];
sh.getRange(row, 1, 1, 5).setValues(head).setFontWeight('bold').setBackground('#173a52').setFontColor('#ffffff');
if (data.length) sh.getRange(row + 1, 1, data.length, 5).setValues(data.map(cleanRow));
row += data.length + 3;
});
// 每天的改动数
var days = {};
v.forEach(function (r) { var d = String(r[0]).slice(0, 10); var x = days[d] || (days[d] = [0, 0]); x[0]++; if (r[COL['风险']] === '高') x[1]++; });
var dd = Object.keys(days).sort().map(function (d) { return [d, days[d][0], days[d][1]]; });
sh.getRange(3, 8, 1, 3).setValues([['日期', '改动数', '高风险']]).setFontWeight('bold').setBackground('#173a52').setFontColor('#ffffff');
if (dd.length) {
sh.getRange(4, 8, dd.length, 1).setNumberFormat('@');
sh.getRange(4, 8, dd.length, 3).setValues(dd);
sh.getCharts().forEach(function (c) { sh.removeChart(c); });
sh.insertChart(sh.newChart().asColumnChart().addRange(sh.getRange(3, 8, dd.length + 1, 3))
.setOption('title', '每天的改动数').setPosition(3, 12, 0, 0).build());
}
sh.setColumnWidth(1, 260);
}
function writeGuide(ss, accounts) {
var rows = [
['账户变更提醒(' + VERSION + ')', ''],
['检查的账户', accounts.map(function (a) { return a.label; }).join('、')],
['每次检查范围', '过去 ' + CONFIG.LOOKBACK_HOURS + ' 小时,已记录过的改动不会重复提醒'],
['信任的用户', CONFIG.TRUSTED_USERS.concat(CONFIG.TRUSTED_DOMAINS).join('、') || '(未设置:所有人的改动都会提醒)'],
['忽略的来源', CONFIG.IGNORE_CLIENT_TYPES.map(function (t) { return cn(CLIENT_CN, t); }).join('、') || '(无)'],
['', ''],
['工作表', '看什么'],
['本次提醒', '这一次运行新发现、符合提醒条件的改动,高风险排在前面'],
['变更记录', '所有改动的完整记录(包括信任用户的),可以筛选用户、来源、广告系列'],
['汇总', '过去 30 天按用户、来源、对象类型、账户统计的改动数,以及每天的改动数'],
['运行日志', '每一步是否成功,出错时看这里'],
['', ''],
['风险等级', '高:预算大幅变化、出价策略或目标变化、广告系列暂停/启用/移除、投放网络和地理位置改变、Google 自动应用的建议'],
['', '中:不在信任名单里的人做的改动、出价大幅变化、网址改变、移除否定关键字、新建广告系列等'],
['', '低:信任用户做的日常改动'],
['注意', '脚本通过接口只能读取最近 30 天的更改记录(所以要持续运行、在表格里积累历史);部分改动(例如转化设置、受众列表、账户层级设置)不在更改记录接口里'],
['教程', 'https://berightdigital.com/google-ads-change-history-alerts/']
];
var sh = ss.getSheetByName('使用说明') || ss.insertSheet('使用说明');
sh.clear();
sh.getRange(1, 1, rows.length, 2).setValues(rows);
sh.getRange(1, 1).setFontSize(14).setFontWeight('bold');
sh.getRange(7, 1, 1, 2).setFontWeight('bold').setBackground('#173a52').setFontColor('#ffffff');
sh.setColumnWidth(1, 160); sh.setColumnWidth(2, 720);
}
// ======================= 邮件 =======================
function sendEmail(ss, alerts, accounts) {
var rank = { '高': 0, '中': 1, '低': 2 };
var list = alerts.slice().sort(function (a, b) { return rank[a.risk] - rank[b.risk] || (a.time < b.time ? 1 : -1); });
var hi = list.filter(function (c) { return c.risk === '高'; }).length;
var name = accounts.length === 1 ? accounts[0].name : accounts.length + ' 个账户';
var subject = '[Google Ads 变更提醒] ' + name + ':' + list.length + ' 条改动' + (hi ? '(高风险 ' + hi + ' 条)' : '');
var h = function (s) { return String(s === undefined || s === null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); };
var shown = list.slice(0, CONFIG.EMAIL_MAX_ROWS);
var html = '<div style="font-family:Arial,sans-serif;font-size:13px;color:#222">' +
'<p>' + h(name) + ' 有 <b>' + list.length + '</b> 条需要留意的改动' + (hi ? ',其中 <b style="color:#c62828">' + hi + '</b> 条高风险' : '') + '。</p>' +
'<table cellpadding="6" cellspacing="0" style="border-collapse:collapse;border:1px solid #ddd">' +
'<tr style="background:#173a52;color:#fff"><th>风险</th><th>时间</th>' + (accounts.length > 1 ? '<th>账户</th>' : '') +
'<th>用户 / 来源</th><th>对象</th><th>改动内容</th><th>原因</th></tr>' +
shown.map(function (c) {
return '<tr style="border-top:1px solid #ddd;vertical-align:top"><td style="background:' + riskColor(c.risk) + '">' + c.risk + '</td>' +
'<td>' + h(c.time) + '</td>' + (accounts.length > 1 ? '<td>' + h(c.account) + '</td>' : '') +
'<td>' + h(c.user || '(无)') + '<br><span style="color:#666">' + h(cn(CLIENT_CN, c.client)) + '</span></td>' +
'<td>' + h([c.campaign, c.group].filter(String).join(' / ')) + '<br><span style="color:#666">' + h(cn(TYPE_CN, c.type) + ' ' + cn(OP_CN, c.op) + ' ' + (c.object || '')) + '</span></td>' +
'<td>' + h(c.detail) + '</td><td>' + h(c.reason) + '</td></tr>';
}).join('') + '</table>' +
(list.length > shown.length ? '<p>另外还有 ' + (list.length - shown.length) + ' 条,见表格。</p>' : '') +
'<p><a href="' + ss.getUrl() + '">打开完整报告</a></p></div>';
var plain = subject + '\n\n' + shown.map(function (c) {
return '[' + c.risk + '] ' + c.time + ' ' + (c.user || cn(CLIENT_CN, c.client)) + ':' + [c.campaign, c.group, c.object].filter(String).join(' / ') + ' - ' + c.detail;
}).join('\n') + '\n\n完整报告:' + ss.getUrl();
MailApp.sendEmail({ to: CONFIG.EMAIL, subject: subject, body: plain, htmlBody: html });
log('信息', '邮件', '已发送到 ' + CONFIG.EMAIL);
}
// ======================= 工具函数 =======================
function openSpreadsheet() {
if (CONFIG.SPREADSHEET_URL) return SpreadsheetApp.openByUrl(CONFIG.SPREADSHEET_URL);
var ss = SpreadsheetApp.create('账户变更提醒 - ' + AdsApp.currentAccount().getName());
log('提示', '表格', '已新建表格,请把这个网址填到 SPREADSHEET_URL:' + ss.getUrl());
Logger.log('★ 新建的表格:' + ss.getUrl());
return ss;
}
function readState(ss) {
var sh = ss.getSheetByName('_状态');
if (!sh || sh.getLastRow() < 1) return {};
return { lastRun: sh.getRange(1, 2).getValue() };
}
function writeState(ss) {
var sh = ss.getSheetByName('_状态') || ss.insertSheet('_状态');
sh.getRange(1, 1, 1, 2).setValues([['上次运行', Utilities.formatDate(new Date(), AdsApp.currentAccount().getTimeZone(), 'yyyy-MM-dd HH:mm:ss')]]);
sh.hideSheet();
}
function runQuery(q) {
var out = [], it = AdsApp.search(q);
while (it.hasNext()) out.push(it.next());
return out;
}
function safe(step, fn) {
try { return fn(); }
catch (e) { log('错误', step, (e && e.message) ? e.message : String(e)); return null; }
}
function log(level, step, msg) {
RUN_LOG.push([new Date(), level, step, msg]);
Logger.log(level + ' | ' + step + ' | ' + msg);
}
function writeLog(ss) {
var sh = ss.getSheetByName('运行日志') || ss.insertSheet('运行日志');
sh.clear();
var rows = [['时间', '级别', '步骤', '内容']].concat(RUN_LOG.map(cleanRow));
sh.getRange(1, 1, rows.length, 4).setValues(rows);
sh.getRange(1, 1, 1, 4).setFontWeight('bold');
sh.setColumnWidth(4, 700);
}
function ensureSheet(ss, name, header) {
var sh = ss.getSheetByName(name);
if (!sh) {
sh = ss.insertSheet(name);
sh.getRange(1, 1, 1, header.length).setValues([header])
.setFontWeight('bold').setBackground('#173a52').setFontColor('#ffffff');
sh.setFrozenRows(1);
}
return sh;
}
function orderSheets(ss) {
['使用说明', '本次提醒', '汇总', '变更记录', '运行日志'].forEach(function (n, i) {
var sh = ss.getSheetByName(n);
if (sh) { ss.setActiveSheet(sh); ss.moveActiveSheet(i + 1); }
});
var st = ss.getSheetByName('_状态'); if (st) st.hideSheet();
['Sheet1', '工作表1', 'Sheet 1'].forEach(function (n) {
var sh = ss.getSheetByName(n);
if (sh && ss.getSheets().length > 1 && sh.getLastRow() === 0) ss.deleteSheet(sh);
});
var first = ss.getSheetByName('本次提醒');
if (first) ss.setActiveSheet(first);
}
function g(obj, path) {
var parts = path.split('.'), cur = obj;
for (var i = 0; i < parts.length; i++) { if (cur === null || cur === undefined) return ''; cur = cur[parts[i]]; }
return cur === undefined || cur === null ? '' : String(cur);
}
function cn(map, v) { return v === '' || v === undefined ? '' : (map[v] !== undefined ? map[v] : String(v)); }
function riskColor(r) { return r === '高' ? '#fde2e1' : r === '中' ? '#fff1d6' : '#eef1f5'; }
// 以 = + - @ 开头的文字会被表格当成公式,前面加 ' 保持为文字
function cleanRow(row) {
return row.map(function (v) {
if (typeof v === 'string' && /^[=+\-@]/.test(v)) return "'" + v;
return v === undefined || v === null ? '' : v;
});
}
如果运行中遇到问题,把「运行日志」里的错误行发给我们,我们会尽快更新脚本。
毕瑞出海BerightDigital微信扫码关注,视频和文章同步更新