搜索字词报告是 Google Ads 里最能直接省钱的地方,但也最费时间:几千条搜索词一条条看,看完还要判断哪些该否定、该用完全匹配还是词组匹配、是不是已经否定过。效果最大化广告系列(PMax)的搜索词更麻烦,后台藏得很深。
这个脚本把这件事自动化:每周(或每天)自动检查搜索广告和 PMax 的搜索字词,按几种规则找出浪费预算的词,最后给出一份去重后、可以直接粘贴的否定关键字建议。脚本综合了几位作者的免费脚本思路,针对外贸询盘和跨境零售做了专门处理。
它能找出哪 5 类问题
| 类型 | 判断规则 | 典型例子 |
|---|---|---|
| 浪费字词 | 花费 ≥ 参考 CPA、点击 ≥ 10、转化 < 0.5 | 一个词花了 80 美元,30 次点击,0 转化 |
| 高 CPC 字词 | 平均点击费用超过所在系列平均的 2 倍 | 系列平均 1.5 美元,这个词 4 美元一次点击 |
| 意外字词 | 搜索词和触发它的关键字一个词都对不上 | 关键字 hiking boots,搜索词 garden hose |
| 浪费词组(N-gram) | 同一个词组出现在 3 个以上的搜索词里,合计花了钱都没转化 | “wholesale” 出现在 12 个搜索词里,合计花费 200 美元 |
| 无效意图 | 包含招聘、免费、DIY、二手等词,或者 B2B/B2C 意图不符 | B2C 店铺出现 “boots factory”,B2B 询盘站出现 “near me” |
参考 CPA 可以自己设定(例如目标每次转化费用 30 美元),不设就用账户最近 30 天的平均每次转化费用。
参考了哪些脚本,改进了什么
我们的 Google Ads 专题 资料库里收录了几个同类脚本,各有所长但都只解决一部分问题:
| 原脚本 | 作者 | 做什么 | 不足 |
|---|---|---|---|
| PMax 无转化搜索字词提醒 | Nils Rooijmans | 找出 PMax 里点击多、没转化的搜索词 | 只看 PMax;默认要 100 次点击才判断;表头依赖作者的外部表格;运行超时会中断 |
| 否定关键字建议(免费版) | Nils Rooijmans | 按转化少的搜索词给否定建议 | 只给单个词的建议,没有词组分析,不检查是否已经否定过 |
| 高 CPC 搜索字词监控 | Francesco Cifardi | 找出点击费用超过固定金额的搜索词 | 阈值是固定金额,不同市场 CPC 差异很大;只看 7 天;每次覆盖结果 |
| 意外搜索字词 | Andrew Boyd | 找出和关键字没有共同词的搜索词 | 不处理单复数和虚词,误报多;只看上个月 |
升级版把四个功能合在一个脚本里,并做了这些改进:
- 同时覆盖搜索广告和 PMax:PMax 优先读取带费用的搜索词数据,账户不支持时自动改用「搜索字词洞察」类别。
- 按 CPA 判断浪费,而不是按固定点击数:花了一个转化的钱还没转化,才算浪费,不同客单价的账户都适用。
- 高 CPC 按系列平均的倍数判断:美国和东南亚的 CPC 能差 10 倍,固定金额阈值没法通用。
- 意外字词处理了单复数、常见虚词和多语言:hiking boot 和 hiking boots 不会被当成对不上;支持英、德、法、西、意等语言的常见虚词。
- N-gram 词组分析:找出反复出现在浪费搜索词里的词,一个词组否定能挡掉一批搜索。
- 外贸/跨境专用的无效意图词表:区分 B2C(排除 wholesale、factory、supplier、OEM 等批发工厂类搜索)和 B2B(排除 near me、cheap、amazon、temu 等零售和平台类搜索)。
- 自动去重:已经在广告系列、广告组或共享列表里否定过的词不会重复建议;邮件只提醒新的建议。
- 品牌词和白名单保护:设置品牌词后,包含品牌词的搜索不会被建议否定。
- 可选自动添加:可以把高优先级的浪费字词自动加入指定的共享否定列表(默认关闭)。
默认情况下脚本只读取数据,不会修改账户。 只有填写了 AUTO_ADD_TO_LIST 才会往你指定的否定列表里加词。
安装步骤(10 分钟)
- 登录 Google Ads,点击左侧「工具」→「批量操作」→「脚本」。
- 点击蓝色「+」按钮,选择「新建脚本」,起个容易认的名字。
- 删掉编辑器里默认的代码,把本文末尾的完整代码全部粘贴进去(也可以从 GitHub 下载)。
- 修改代码开头
CONFIG里的设置,至少填好EMAIL(接收提醒的邮箱)。 - 点击「授权」,用有这个账户权限的 Google 账号登录并允许访问。
- 点击「预览」。第一次运行会自动新建一个 Google 表格,在下方「日志」里找到以「★ 新建的表格」开头的一行,复制网址填到
SPREADSHEET_URL的两个引号中间,保存。 - 回到脚本列表,设置运行频率(见下方建议)。
预览模式下脚本也会正常写入表格,但不会修改账户里的任何设置。
建议运行频率:每周一次。搜索词数据需要积累,天天看反而容易被少量点击误导。预算很高的账户可以每天运行。
设置说明
| 设置 | 默认值 | 说明 |
|---|---|---|
| SPREADSHEET_URL | 空 | 报告表格网址,第一次运行后填入 |
| 空 | 有新的否定建议时发邮件 | |
| CAMPAIGN_NAME_CONTAINS / EXCLUDES | 空 | 只分析或排除名称包含某段文字的广告系列 |
| DAYS | 30 | 分析最近多少天的数据 |
| BUSINESS | B2C | 业务类型:B2C 跨境零售,或 B2B 外贸询盘 |
| TARGET_CPA | 0 | 目标每次转化费用;0 = 用账户平均值 |
| WASTE_CPA_MULTIPLIER | 1 | 花费达到参考 CPA 的几倍还没转化,算浪费 |
| MIN_CLICKS | 10 | 至少要有几次点击才判断 |
| HIGH_CPC_MULTIPLIER | 2 | CPC 超过系列平均的几倍算高 |
| HIGH_CPC_ABS | 0 | 也可以直接设一个 CPC 上限 |
| NGRAM_MIN_TERMS | 3 | 词组至少出现在几个搜索词里才判断 |
| BRAND_TERMS | 空 | 品牌词,包含它们的搜索不会被建议否定 |
| KEEP_TERMS | 空 | 白名单,确定不想否定的词 |
| EXTRA_BAD_WORDS | 空 | 自己补充的无效意图词 |
| AUTO_ADD_TO_LIST | 空 | 自动加否定的共享列表名称;空 = 只给建议 |
怎么看报告
行动清单
先看这里。第一行是汇总:多少个搜索词花了钱没转化、合计花了多少、占搜索词总花费的比例。下面依次是花费最多的浪费字词、浪费词组、高 CPC 字词、意外字词和无效意图词的汇总。
否定关键字建议
脚本已经帮你去掉了重复的、已经否定过的、包含品牌词的建议。「否定词」列已经带好了匹配方式的符号:
[hiking boots wholesale]:完全匹配,只挡这一个搜索词。浪费字词默认用完全匹配,最安全。"wholesale":词组匹配,挡住所有包含这个词组的搜索。来自 N-gram 分析,影响面大,加之前要确认不会误伤。
确认后,选中「否定词」列复制,粘贴到 Google Ads 的「工具 → 共享库 → 排除关键字列表」,或者广告系列的否定关键字里即可。
搜索广告字词 / PMax 字词
所有搜索词的明细,「标签」列标出了每个词命中的问题,「说明」列写了具体原因。可以按「标签」筛选。
N-gram
把所有搜索词拆成 1 个词、2 个词、3 个词的组合,再汇总花费和转化。这张表也能反过来用:转化多、ROAS 高的词组,往往值得单独建关键字或广告组。
B2B 和 B2C 的否定思路不一样
同一个词,对不同的生意意义完全相反:
| 搜索里出现 | 跨境零售(B2C) | 外贸询盘(B2B) |
|---|---|---|
| wholesale、bulk、factory、supplier、OEM | 多半是想找工厂的采购商,零售站接不住 → 否定 | 正是想要的客户 → 保留 |
| near me、cheap、for sale、single | 正常购物意图 → 保留 | 个人买家,询盘价值低 → 否定 |
| amazon、temu、aliexpress、etsy | 想去平台买,通常不会在独立站下单 → 视情况 | 零售意图 → 否定 |
| free、jobs、diy、pdf、used | 都不是买家 → 否定 | 都不是买家 → 否定 |
所以第一件事是把 BUSINESS 设对。内置词表只是起点,建议结合自己行业补充 EXTRA_BAD_WORDS,例如做机械的可以加 parts diagram、how does。
否定关键字的几个原则
- 先完全匹配,后词组匹配:单个搜索词用完全匹配否定,几乎不会误伤;词组否定要先看 N-gram 表确认这个词组下没有转化。
- 不要否定单个产品词:例如 “boots” 出现在大量浪费词里,是因为它出现在所有搜索里,否定它等于关掉广告。
- 用共享否定列表管理:按主题建几个列表(招聘类、免费类、竞品平台类),关联到所有相关广告系列,比在每个系列里单独加更好维护。
- PMax 的否定要单独处理:PMax 现在支持在广告系列层面添加否定关键字,也可以用账户层面的否定关键字列表。
- 定期回看:每月看一次「搜索广告字词」里被标为「意外」的词,有些其实是新的高转化关键字。
常见问题
PMax 字词为什么只显示类别、没有费用?
Google 对 PMax 搜索词的开放程度在变化。脚本会先尝试读取带费用的搜索词数据;如果你的账户还不支持,会改用「搜索字词洞察」的类别数据。这种数据只有点击和转化,没有费用,所以不会被判定为浪费,只能作为参考。
为什么有些花钱很多的词没出现在建议里?
可能的原因:它有转化(哪怕 0.5 次);点击数没达到 MIN_CLICKS;包含品牌词或白名单词;已经被否定过。
「意外字词」都要否定吗?
不一定。意外字词说明匹配范围比你想的宽,常见于广泛匹配和 AI Max。没有转化的才进否定建议,有转化的说明 Google 帮你找到了新词,可以考虑加成关键字。
自动加否定安全吗?
只会加「高优先级」的浪费字词,而且是完全匹配,影响范围很小。但仍然建议先手动运行几周,确认建议质量后再打开。
数据量很大会超时吗?
脚本按字词汇总后处理,几万个搜索词一般几分钟内完成。PMax 类别数据需要逐个广告系列查询,接近 30 分钟上限时会自动停止并记录在日志里。
完整代码
代码也放在 GitHub 上,可以直接下载:search-term-guard.js。复制时点击代码框右上角的按钮,或者全选后复制。
/**
* 搜索字词守卫 v1.0(毕瑞出海中文版)
* ------------------------------------------------------------
* 作用:检查搜索广告和效果最大化(PMax)广告系列的搜索字词,找出
* 1. 花了钱没转化的字词(按目标 CPA 或账户平均 CPA 判断);
* 2. 点击费用明显偏高的字词;
* 3. 和触发关键字一个词都对不上的「意外字词」;
* 4. 反复出现在浪费字词里的词组(N-gram 分析),适合加词组否定;
* 5. 外贸/跨境常见的无效意图词:招聘、免费、DIY、二手、竞品平台、B2B/B2C 意图不符等;
* 并生成去重后的「否定关键字建议」,可选自动加入共享否定关键字列表。
*
* 思路参考 Nils Rooijmans(PMax 无转化字词提醒、否定关键字建议)、
* Francesco Cifardi(高 CPC 字词监控)、Andrew Boyd(意外搜索字词)的脚本,代码为重新编写。
*
* 教程:https://berightdigital.com/google-ads-search-term-guard-script/
* ------------------------------------------------------------
*/
// ======================= 设置(只需要改这里) =======================
var CONFIG = {
// Google 表格网址。留空 = 第一次运行时自动新建,网址打印在「日志」里,复制回这里。
SPREADSHEET_URL: '',
// 有新的否定建议时发邮件(留空 = 不发)。多个邮箱用英文逗号分隔。
EMAIL: '',
// 只分析名称包含 / 不包含这段文字的广告系列(留空 = 不限)
CAMPAIGN_NAME_CONTAINS: '',
CAMPAIGN_NAME_EXCLUDES: '',
// 分析最近多少天的数据(截至昨天)
DAYS: 30,
// 业务类型:'B2C'(跨境零售,想排除批发/工厂类搜索)或 'B2B'(外贸询盘,想排除零售/个人类搜索)
BUSINESS: 'B2C',
// 浪费字词判断:花费 ≥ 参考 CPA × 这个倍数,且转化 < 0.5,就算浪费
// 参考 CPA:填了 TARGET_CPA 就用它,否则用账户近期平均每次转化费用
TARGET_CPA: 0,
WASTE_CPA_MULTIPLIER: 1,
// 同时至少要有这么多次点击(避免一次误点就被判定)
MIN_CLICKS: 10,
// 高 CPC:平均点击费用 > 所在广告系列平均 CPC × 这个倍数(且点击 ≥ 3)
HIGH_CPC_MULTIPLIER: 2,
// 或者直接设一个上限(账户币种,0 = 不用)
HIGH_CPC_ABS: 0,
// N-gram:同一个词组出现在至少几个搜索字词里、合计花费 ≥ 参考 CPA,且没有转化,就建议加词组否定
NGRAM_MIN_TERMS: 3,
// 品牌词(这些词出现时不提否定建议),例如 ['yourbrand', 'your brand']
BRAND_TERMS: [],
// 你确定不想否定的词(白名单)
KEEP_TERMS: [],
// 额外的无效意图词(会和内置词表合并)
EXTRA_BAD_WORDS: [],
// 自动把「浪费字词」以完全匹配加入这个共享否定关键字列表(留空 = 只给建议,不改账户)
// 列表需要先在「工具 → 共享库 → 排除关键字列表」里建好,并关联到广告系列
AUTO_ADD_TO_LIST: '',
// 每封邮件最多列出多少条
EMAIL_MAX_ROWS: 40
};
// ======================= 设置结束,下面的代码一般不需要改 =======================
var VERSION = 'v1.0';
// 内置无效意图词(多语言)。命中后只是「建议」,请结合业务判断。
var BAD_WORDS_COMMON = [
'free', 'gratis', 'kostenlos', 'gratuit', 'gratuito',
'job', 'jobs', 'career', 'careers', 'salary', 'hiring', 'vacancy', 'internship', 'resume', 'empleo', 'trabajo', 'stellenangebot',
'diy', 'how to make', 'tutorial', 'pdf', 'download', 'template', 'meaning', 'definition', 'wiki', 'reddit', 'youtube',
'used', 'second hand', 'secondhand', 'repair', 'manual', 'login', 'customer service', 'phone number', 'coupon code'
];
var BAD_WORDS_B2C = ['wholesale', 'bulk', 'manufacturer', 'manufacturers', 'factory', 'supplier', 'suppliers', 'oem', 'odm', 'distributor', 'alibaba', 'made in china'];
var BAD_WORDS_B2B = ['near me', 'for sale', 'single', 'one piece', 'cheap', 'amazon', 'ebay', 'walmart', 'temu', 'shein', 'aliexpress', 'etsy', 'review', 'reviews', 'coupon'];
var STOP_WORDS = ['a', 'an', 'the', 'and', 'or', 'for', 'of', 'in', 'on', 'to', 'with', 'by', 'at', 'from', 'my', 'me',
'de', 'la', 'le', 'les', 'el', 'los', 'las', 'del', 'des', 'du', 'et', 'y', 'en', 'para', 'pour', 'con', 'avec',
'der', 'die', 'das', 'und', 'für', 'mit', 'von', 'il', 'di', 'e', 'per'];
function main() {
var ss = openSpreadsheet('搜索字词守卫');
log('信息', '开始', '版本 ' + VERSION + ',分析最近 ' + CONFIG.DAYS + ' 天,业务类型 ' + CONFIG.BUSINESS);
var ref = referenceCpa();
log('信息', '参考 CPA', CONFIG.TARGET_CPA ? '使用设置的目标 CPA ' + ref : '使用账户近期平均每次转化费用 ' + r2(ref));
var wasteCost = ref > 0 ? ref * CONFIG.WASTE_CPA_MULTIPLIER : 0;
var search = safe('搜索广告字词', fetchSearchTerms) || [];
var pmax = safe('PMax 字词', fetchPmaxTerms) || [];
log('信息', '字词', '搜索广告 ' + search.length + ' 个,PMax ' + pmax.length + ' 个');
var negs = safe('已有否定关键字', fetchNegatives) || { set: {}, n: 0 };
log('信息', '否定关键字', '读取到 ' + negs.n + ' 个已有否定关键字(广告系列、广告组、共享列表)');
var campCpc = {};
search.forEach(function (t) { var c = campCpc[t.campaign] || (campCpc[t.campaign] = { cost: 0, clicks: 0 }); c.cost += t.cost; c.clicks += t.clicks; });
var badWords = BAD_WORDS_COMMON.concat(CONFIG.BUSINESS === 'B2B' ? BAD_WORDS_B2B : BAD_WORDS_B2C).concat(CONFIG.EXTRA_BAD_WORDS || []);
var all = search.concat(pmax);
all.forEach(function (t) {
t.norm = normalize(t.term);
t.protected = hasAny(t.norm, CONFIG.BRAND_TERMS) || hasAny(t.norm, CONFIG.KEEP_TERMS);
t.negated = !!negs.set[t.norm];
t.flags = [];
if (!t.protected && t.conv < 0.5 && t.clicks >= CONFIG.MIN_CLICKS && (wasteCost ? t.cost >= wasteCost : t.cost > 0))
t.flags.push('浪费');
var cc = campCpc[t.campaign];
var avg = cc && cc.clicks ? cc.cost / cc.clicks : 0;
t.cpc = t.clicks ? t.cost / t.clicks : 0;
if (t.clicks >= 3 && ((CONFIG.HIGH_CPC_ABS && t.cpc > CONFIG.HIGH_CPC_ABS) || (avg && t.cpc > avg * CONFIG.HIGH_CPC_MULTIPLIER)))
t.flags.push('高CPC');
if (t.keyword && !t.protected && !overlap(t.term, t.keyword)) t.flags.push('意外');
var bw = badWords.filter(function (w) { return containsWord(t.norm, normalize(w)); });
if (bw.length && !t.protected) { t.flags.push('无效意图'); t.badWords = bw; }
t.avgCpc = avg;
});
var grams = safe('N-gram 分析', function () { return ngrams(all, wasteCost); }) || [];
var sugg = buildSuggestions(all, grams, negs);
log('信息', '建议', '否定关键字建议 ' + sugg.length + ' 条');
safe('否定关键字建议', function () { writeSuggestions(ss, sugg); });
safe('搜索广告字词', function () { writeTerms(ss, '搜索广告字词', search, true); });
safe('PMax 字词', function () { writeTerms(ss, 'PMax 字词', pmax, false); });
safe('N-gram', function () { writeGrams(ss, grams); });
safe('行动清单', function () { writeActions(ss, buildActions(all, grams, sugg, ref), '近期搜索字词没有发现明显浪费'); });
if (CONFIG.AUTO_ADD_TO_LIST) safe('自动加否定', function () { autoAdd(sugg); });
var fresh = safe('提醒去重', function () { return newOnes(ss, sugg); }) || [];
if (CONFIG.EMAIL && fresh.length) safe('邮件', function () {
var rows = [['优先级', '否定词', '匹配', '原因', '费用', '点击', '来源']].concat(fresh.slice(0, CONFIG.EMAIL_MAX_ROWS).map(function (s) {
return [s.level, s.text, s.match, s.reason, s.cost, s.clicks, s.source];
}));
mail('[搜索字词守卫] ' + AdsApp.currentAccount().getName() + ':' + fresh.length + ' 条新的否定建议',
'最近 ' + CONFIG.DAYS + ' 天的搜索字词里发现 ' + fresh.length + ' 条新的否定关键字建议。确认后加到否定关键字列表即可。', rows, ss);
});
safe('使用说明', function () {
writeGuide(ss, '搜索字词守卫', VERSION, [
['数据范围', '最近 ' + CONFIG.DAYS + ' 天(截至昨天),搜索广告 + 效果最大化广告系列'],
['参考 CPA', r2(ref) + (CONFIG.TARGET_CPA ? '(设置的目标 CPA)' : '(账户平均每次转化费用)')],
['浪费字词标准', '花费 ≥ ' + r2(wasteCost) + '、点击 ≥ ' + CONFIG.MIN_CLICKS + '、转化 < 0.5'],
['业务类型', CONFIG.BUSINESS + (CONFIG.BUSINESS === 'B2B' ? ':零售/个人意图的搜索会被标为无效意图' : ':批发/工厂意图的搜索会被标为无效意图')],
['', ''],
['行动清单', '先看这里:最该处理的浪费字词、词组和高 CPC 字词'],
['否定关键字建议', '去重后的建议(已经否定过的不会重复出现),可以直接复制「否定词」列粘贴到否定关键字列表'],
['搜索广告字词 / PMax 字词', '所有字词的明细和标签:浪费、高CPC、意外、无效意图'],
['N-gram', '把搜索字词拆成 1–3 个词的词组再汇总,找出反复出现在浪费字词里的词'],
['注意', 'PMax 的字词数据可能不完整(Google 只提供部分数据);否定前请确认不会误伤正常流量']
]);
});
writeLog(ss);
orderSheets(ss, ['使用说明', '行动清单', '否定关键字建议', '搜索广告字词', 'PMax 字词', 'N-gram', '运行日志']);
}
// ======================= 读取数据 =======================
function referenceCpa() {
if (CONFIG.TARGET_CPA) return CONFIG.TARGET_CPA;
var rows = tryQuery('参考 CPA', 'SELECT metrics.cost_micros, metrics.conversions FROM customer WHERE ' + range(CONFIG.DAYS)) || [];
var cost = 0, conv = 0;
rows.forEach(function (r) { cost += money(g(r, 'metrics.costMicros')); conv += num(g(r, 'metrics.conversions')); });
return conv >= 1 ? cost / conv : 0;
}
function fetchSearchTerms() {
var base = 'SELECT search_term_view.search_term, search_term_view.status, campaign.name, ad_group.name, ' +
'segments.keyword.info.text, segments.keyword.info.match_type, ' +
'metrics.impressions, metrics.clicks, metrics.cost_micros, metrics.conversions, metrics.conversions_value ' +
'FROM search_term_view WHERE ' + range(CONFIG.DAYS) + ' AND campaign.advertising_channel_type = "SEARCH" AND metrics.impressions > 0' + nameFilter();
var rows = tryQuery('搜索广告字词', base);
if (!rows) rows = runQuery(base.replace('segments.keyword.info.text, segments.keyword.info.match_type, ', ''));
var map = {};
rows.forEach(function (r) {
var term = String(g(r, 'searchTermView.searchTerm'));
var kw = String(g(r, 'segments.keyword.info.text'));
var key = g(r, 'campaign.name') + '|' + g(r, 'adGroup.name') + '|' + term + '|' + kw;
if (!map[key]) map[key] = zero({ source: '搜索', term: term, campaign: String(g(r, 'campaign.name')), group: String(g(r, 'adGroup.name')),
keyword: kw, match: String(g(r, 'segments.keyword.info.matchType')), status: String(g(r, 'searchTermView.status')) });
addMetrics(map[key], metricsOf(r));
});
// 已经被排除的字词不再分析
return values(map).filter(function (t) { return t.status !== 'EXCLUDED' && t.status !== 'ADDED_EXCLUDED'; });
}
// PMax 字词:优先使用 campaign_search_term_view(有费用数据);不支持时退回到「搜索字词洞察」类别
function fetchPmaxTerms() {
var q = 'SELECT campaign.name, campaign_search_term_view.search_term, metrics.impressions, metrics.clicks, metrics.cost_micros, ' +
'metrics.conversions, metrics.conversions_value FROM campaign_search_term_view WHERE ' + range(CONFIG.DAYS) +
' AND campaign.advertising_channel_type = "PERFORMANCE_MAX" AND metrics.impressions > 0' + nameFilter();
var rows = tryQuery('PMax 字词', q);
var map = {};
if (rows) {
rows.forEach(function (r) {
var term = String(g(r, 'campaignSearchTermView.searchTerm'));
var key = g(r, 'campaign.name') + '|' + term;
if (!map[key]) map[key] = zero({ source: 'PMax', term: term, campaign: String(g(r, 'campaign.name')), group: '', keyword: '', match: '' });
addMetrics(map[key], metricsOf(r));
});
return values(map);
}
log('提示', 'PMax 字词', '改用「搜索字词洞察」类别(没有费用数据,只能看点击和转化)');
var camps = tryQuery('PMax 广告系列', 'SELECT campaign.id, campaign.name FROM campaign WHERE campaign.advertising_channel_type = "PERFORMANCE_MAX" AND campaign.status = "ENABLED"' + nameFilter()) || [];
camps.forEach(function (c) {
if (timeLeft() < 5 * 60 * 1000) return;
var ins = tryQuery('搜索字词洞察', 'SELECT campaign_search_term_insight.category_label, metrics.impressions, metrics.clicks, metrics.conversions, metrics.conversions_value ' +
'FROM campaign_search_term_insight WHERE ' + range(CONFIG.DAYS) + ' AND campaign_search_term_insight.campaign_id = "' + g(c, 'campaign.id') + '"') || [];
ins.forEach(function (r) {
var label = String(g(r, 'campaignSearchTermInsight.categoryLabel') || '(未分类)');
var key = g(c, 'campaign.name') + '|' + label;
if (!map[key]) map[key] = zero({ source: 'PMax 类别', term: label, campaign: String(g(c, 'campaign.name')), group: '', keyword: '', match: '' });
addMetrics(map[key], metricsOf(r));
});
});
return values(map);
}
// 已有否定关键字:广告系列、广告组、共享列表
function fetchNegatives() {
var set = {}, n = 0;
function add(t) { var k = normalize(t); if (k && !set[k]) { set[k] = 1; n++; } }
(tryQuery('广告系列否定', 'SELECT campaign_criterion.keyword.text FROM campaign_criterion WHERE campaign_criterion.negative = TRUE AND campaign_criterion.type = "KEYWORD"') || [])
.forEach(function (r) { add(g(r, 'campaignCriterion.keyword.text')); });
(tryQuery('广告组否定', 'SELECT ad_group_criterion.keyword.text FROM ad_group_criterion WHERE ad_group_criterion.negative = TRUE AND ad_group_criterion.type = "KEYWORD" AND ad_group_criterion.status != "REMOVED"') || [])
.forEach(function (r) { add(g(r, 'adGroupCriterion.keyword.text')); });
(tryQuery('共享否定列表', 'SELECT shared_criterion.keyword.text FROM shared_criterion WHERE shared_criterion.type = "KEYWORD"') || [])
.forEach(function (r) { add(g(r, 'sharedCriterion.keyword.text')); });
return { set: set, n: n };
}
// ======================= 文字处理 =======================
function normalize(s) {
return String(s || '').toLowerCase().replace(/[\[\]"+]/g, ' ').replace(/[^\p{L}\p{N}\s'-]/gu, ' ').replace(/\s+/g, ' ').trim();
}
function stem(w) {
if (w.length > 4 && /ies$/.test(w)) return w.slice(0, -3) + 'y';
if (w.length > 4 && /(ches|shes|xes|sses)$/.test(w)) return w.slice(0, -2);
if (w.length > 3 && /s$/.test(w) && !/ss$/.test(w)) return w.slice(0, -1);
return w;
}
function tokens(s) {
return normalize(s).split(' ').filter(function (w) { return w && STOP_WORDS.indexOf(w) < 0; }).map(stem);
}
// 搜索字词和关键字至少有一个词相同(忽略单复数和常见虚词)
function overlap(term, kw) {
var a = tokens(term), b = tokens(kw);
if (!a.length || !b.length) return true;
for (var i = 0; i < a.length; i++) {
for (var j = 0; j < b.length; j++) {
if (a[i] === b[j]) return true;
// 拼写相近:一个词是另一个词的开头(例如 sneaker / sneakers、tshirt / tshirts)
if (a[i].length >= 4 && b[j].length >= 4 && (a[i].indexOf(b[j]) === 0 || b[j].indexOf(a[i]) === 0)) return true;
}
}
return false;
}
function containsWord(norm, w) {
if (!w) return false;
return (' ' + norm + ' ').indexOf(' ' + w + ' ') >= 0;
}
function hasAny(norm, list) {
return (list || []).some(function (w) { w = normalize(w); return w && norm.indexOf(w) >= 0; });
}
function ngrams(all, wasteCost) {
var map = {};
all.forEach(function (t) {
if (t.source === 'PMax 类别') return;
var w = normalize(t.term).split(' ').filter(String), seen = {};
for (var n = 1; n <= 3; n++) {
for (var i = 0; i + n <= w.length; i++) {
var gram = w.slice(i, i + n).join(' ');
if (seen[gram]) continue;
if (n === 1 && (STOP_WORDS.indexOf(gram) >= 0 || gram.length < 3)) continue;
seen[gram] = 1;
var x = map[gram] || (map[gram] = zero({ gram: gram, n: n, terms: 0, protected: false }));
x.terms++; addMetrics(x, t);
if (t.protected) x.protected = true;
}
}
});
var minCost = wasteCost || 0;
return values(map).filter(function (x) { return x.terms >= 2; }).map(function (x) {
x.waste = !x.protected && x.terms >= CONFIG.NGRAM_MIN_TERMS && x.conv < 0.5 && x.cost > 0 && x.cost >= minCost;
return x;
}).sort(function (a, b) { return b.cost - a.cost; });
}
// ======================= 建议与输出 =======================
function buildSuggestions(all, grams, negs) {
var out = {}, rank = { '高': 0, '中': 1, '低': 2 };
function add(text, match, level, reason, m, source, campaign) {
var key = normalize(text);
if (!key || negs.set[key]) return;
var cur = out[key];
if (cur && rank[cur.level] <= rank[level]) { if (cur.reason.indexOf(reason) < 0) cur.reason += ';' + reason; return; }
out[key] = { text: match === '完全' ? '[' + key + ']' : match === '词组' ? '"' + key + '"' : key, key: key, match: match, level: level,
reason: reason, cost: m.cost, clicks: m.clicks, conv: m.conv, source: source, campaign: campaign || '' };
}
all.forEach(function (t) {
if (t.protected || t.negated || t.source === 'PMax 类别') return;
if (t.flags.indexOf('浪费') >= 0) add(t.term, '完全', '高', '花费 ' + r2(t.cost) + ',没有转化', t, t.source, t.campaign);
else if (t.flags.indexOf('无效意图') >= 0 && t.conv < 0.5 && t.cost > 0) add(t.term, '完全', '中', '包含无效意图词:' + t.badWords.join('、'), t, t.source, t.campaign);
else if (t.flags.indexOf('意外') >= 0 && t.conv < 0.5 && t.clicks >= 3) add(t.term, '完全', '低', '和触发关键字「' + t.keyword + '」没有共同的词', t, t.source, t.campaign);
});
grams.forEach(function (x) {
if (x.waste) add(x.gram, '词组', '中', '出现在 ' + x.terms + ' 个字词里,合计花费 ' + r2(x.cost) + ',没有转化', x, 'N-gram', '');
});
return values(out).sort(function (a, b) { return rank[a.level] - rank[b.level] || b.cost - a.cost; });
}
function writeSuggestions(ss, sugg) {
write(ss, '否定关键字建议', ['优先级', '否定词', '匹配方式', '原因', '费用', '点击', '转化', '来源', '广告系列'],
sugg.map(function (s) { return [s.level, s.text, s.match, s.reason, s.cost, s.clicks, s.conv, s.source, s.campaign]; }),
['', '', '', '', FMT.money, FMT.int, FMT.dec, '', ''],
{ widths: [70, 260, 80, 380, 90, 70, 70, 80, 220], colors: { col: 0, fn: levelColor }, empty: '没有新的否定建议' });
}
function writeTerms(ss, name, list, isSearch) {
list.sort(function (a, b) { return b.cost - a.cost; });
var head = (isSearch ? ['广告系列', '广告组', '搜索字词', '触发关键字', '匹配方式'] : ['广告系列', '搜索字词 / 类别']).concat(METRIC_HEAD).concat(['标签', '说明']);
var fm = (isSearch ? ['', '', '', '', ''] : ['', '']).concat(METRIC_FMT).concat(['', '']);
var data = list.map(function (t) {
var base = isSearch ? [t.campaign, t.group, t.term, t.keyword, t.match] : [t.campaign, t.term];
var note = [];
if (t.negated) note.push('已否定');
if (t.protected) note.push('品牌词/白名单');
if (t.flags.indexOf('高CPC') >= 0) note.push('CPC ' + r2(t.cpc) + ',系列平均 ' + r2(t.avgCpc));
if (t.badWords) note.push('命中:' + t.badWords.join('、'));
return base.concat(metricCells(t)).concat([t.flags.join('、'), note.join(';')]);
});
var flagCol = head.indexOf('标签');
write(ss, name, head, data, fm, { max: 20000, colors: { col: flagCol, fn: function (v) { return /浪费/.test(v) ? '#fde2e1' : v ? '#fff1d6' : null; } } });
}
function writeGrams(ss, grams) {
write(ss, 'N-gram', ['词组', '词数', '出现在几个字词里'].concat(METRIC_HEAD).concat(['建议']),
grams.slice(0, 5000).map(function (x) { return [x.gram, x.n, x.terms].concat(metricCells(x)).concat([x.waste ? '建议加词组否定' : (x.protected ? '品牌词/白名单' : '')]); }),
['', FMT.int, FMT.int].concat(METRIC_FMT).concat(['']),
{ colors: { col: 12, fn: function (v) { return v === '建议加词组否定' ? '#fde2e1' : null; } } });
}
function buildActions(all, grams, sugg, ref) {
var A = [];
var wasted = all.filter(function (t) { return t.flags.indexOf('浪费') >= 0 && !t.negated; });
var wCost = wasted.reduce(function (s, t) { return s + t.cost; }, 0);
var total = all.reduce(function (s, t) { return s + (t.source === 'PMax 类别' ? 0 : t.cost); }, 0);
if (wasted.length) A.push(['高', '汇总', '全部字词', wasted.length + ' 个字词花了钱没有转化', '合计 ' + r2(wCost) + '(占字词总花费 ' + pct(div(wCost, total)) + ')',
'见「否定关键字建议」,确认后以完全匹配加入否定列表']);
wasted.sort(function (a, b) { return b.cost - a.cost; }).slice(0, 15).forEach(function (t) {
A.push(['高', t.source + '字词', t.campaign + (t.group ? ' / ' + t.group : '') + ' / ' + t.term, '花费 ' + r2(t.cost) + ',' + t.clicks + ' 次点击,没有转化',
t.keyword ? '触发关键字:' + t.keyword : '', '加完全匹配否定;如果是重要词,先检查落地页和报价是否匹配这个搜索']);
});
grams.filter(function (x) { return x.waste; }).slice(0, 10).forEach(function (x) {
A.push(['中', '词组', '“' + x.gram + '”', '出现在 ' + x.terms + ' 个字词里都没有转化', '合计花费 ' + r2(x.cost) + ',' + x.clicks + ' 次点击', '考虑加词组否定 "' + x.gram + '"(会挡住所有包含这个词组的搜索,先确认不会误伤)']);
});
all.filter(function (t) { return t.flags.indexOf('高CPC') >= 0; }).sort(function (a, b) { return b.cost - a.cost; }).slice(0, 10).forEach(function (t) {
A.push(['中', '高 CPC', t.campaign + ' / ' + t.term, '点击费用 ' + r2(t.cpc) + ',是系列平均的 ' + r2(div(t.cpc, t.avgCpc)) + ' 倍', '转化 ' + r2(t.conv) + ',费用 ' + r2(t.cost),
t.conv >= 1 ? '有转化,可以单独建完全匹配关键字控制出价' : '没有转化的话建议否定,或降低对应关键字出价']);
});
var unexp = all.filter(function (t) { return t.flags.indexOf('意外') >= 0; });
if (unexp.length) {
var uc = unexp.reduce(function (s, t) { return s + t.cost; }, 0);
A.push(['中', '匹配', '搜索广告', unexp.length + ' 个字词和触发关键字没有共同的词', '合计花费 ' + r2(uc), '通常来自广泛匹配或 AI Max。检查「搜索广告字词」表里标为「意外」的字词,相关的加为关键字,不相关的否定']);
}
var bad = all.filter(function (t) { return t.flags.indexOf('无效意图') >= 0 && t.conv < 0.5; });
if (bad.length) A.push(['中', '意图', '全部字词', bad.length + ' 个字词包含无效意图词', '例如:' + bad.slice(0, 5).map(function (t) { return t.term; }).join('、'),
CONFIG.BUSINESS === 'B2B' ? '外贸询盘账户建议否定零售、个人、电商平台类搜索' : '跨境零售账户建议否定批发、工厂、供应商类搜索']);
if (!ref) A.push(['低', '说明', '—', '账户近期没有转化,无法计算参考 CPA', '', '请在设置里填 TARGET_CPA,否则「浪费字词」只按点击数判断']);
return A;
}
// 只提醒以前没有提醒过的建议
function newOnes(ss, sugg) {
var sh = ss.getSheetByName('_已提醒') || ss.insertSheet('_已提醒');
var seen = {}, n = sh.getLastRow();
if (n) sh.getRange(1, 1, n, 1).getValues().forEach(function (r) { seen[String(r[0])] = 1; });
var fresh = sugg.filter(function (s) { return !seen[s.key]; });
if (fresh.length && !isPreview()) sh.getRange(n + 1, 1, fresh.length, 1).setNumberFormat('@').setValues(fresh.map(function (s) { return [s.key]; }));
sh.hideSheet();
return fresh;
}
function autoAdd(sugg) {
var it = AdsApp.negativeKeywordLists().withCondition("Name = '" + CONFIG.AUTO_ADD_TO_LIST.replace(/'/g, '') + "'").get();
if (!it.hasNext()) { log('错误', '自动加否定', '找不到共享否定列表「' + CONFIG.AUTO_ADD_TO_LIST + '」'); return; }
var list = it.next(), n = 0;
sugg.filter(function (s) { return s.match === '完全' && s.level === '高'; }).forEach(function (s) { list.addNegativeKeyword('[' + s.key + ']'); n++; });
log('信息', '自动加否定', (isPreview() ? '(预览模式不会真的添加)' : '') + '加入「' + CONFIG.AUTO_ADD_TO_LIST + '」' + n + ' 个完全匹配否定');
}
// ======================= 通用工具函数(毕瑞出海脚本库共用) =======================
var RUN_LOG = [];
var STARTED = new Date();
var FMT = { money: '#,##0.00', int: '#,##0', pct: '0.0%', dec: '#,##0.00', ratio: '0.00' };
var HEAD_BG = '#173a52';
function openSpreadsheet(title) {
if (CONFIG.SPREADSHEET_URL) return SpreadsheetApp.openByUrl(CONFIG.SPREADSHEET_URL);
var ss = SpreadsheetApp.create(title + ' - ' + AdsApp.currentAccount().getName());
log('提示', '表格', '已新建表格,请把这个网址填到 SPREADSHEET_URL:' + ss.getUrl());
Logger.log('★ 新建的表格:' + ss.getUrl());
return ss;
}
function runQuery(q) {
var out = [], it = AdsApp.search(q);
while (it.hasNext()) out.push(it.next());
return out;
}
// 查询失败时返回 null,并把原因写进日志(不让一个查询拖垮整个脚本)
function tryQuery(step, q) {
try { return runQuery(q); }
catch (e) { log('提示', step, '查询失败:' + (e && e.message ? e.message : e)); return null; }
}
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, String(msg)]);
Logger.log(level + ' | ' + step + ' | ' + msg);
}
function timeLeft() { return 28 * 60 * 1000 - (new Date() - STARTED); }
function isPreview() { try { return AdsApp.getExecutionInfo().isPreview(); } catch (e) { return false; } }
function tz() { return AdsApp.currentAccount().getTimeZone(); }
function today() { return Utilities.formatDate(new Date(), tz(), 'yyyy-MM-dd'); }
function dayStr(offsetDays) { return Utilities.formatDate(new Date(Date.now() + offsetDays * 864e5), tz(), 'yyyy-MM-dd'); }
// 截至昨天的 N 天
function range(days) { return ' segments.date BETWEEN "' + dayStr(-Number(days)) + '" AND "' + dayStr(-1) + '" '; }
function between(from, to) { return ' segments.date BETWEEN "' + from + '" AND "' + to + '" '; }
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 ? '' : cur;
}
function num(v) { var x = Number(v); return isNaN(x) ? 0 : x; }
function money(micros) { return num(micros) / 1e6; }
function div(a, b) { return b ? a / b : ''; }
function r2(x) { return x === '' || x === null || x === undefined ? '—' : Math.round(num(x) * 100) / 100; }
function pct(x) { return x === '' || x === null || x === undefined ? '—' : Math.round(num(x) * 1000) / 10 + '%'; }
function values(o) { return Object.keys(o).map(function (k) { return o[k]; }); }
function clean(s) { return String(s || '').replace(/["\\]/g, ''); }
function nameFilter(field) {
var inc = clean(CONFIG.CAMPAIGN_NAME_CONTAINS), exc = clean(CONFIG.CAMPAIGN_NAME_EXCLUDES);
field = field || 'campaign.name';
return (inc ? ' AND ' + field + ' LIKE "%' + inc.replace(/%/g, '') + '%"' : '') +
(exc ? ' AND ' + field + ' NOT LIKE "%' + exc.replace(/%/g, '') + '%"' : '');
}
function metricsOf(r) {
return { impr: num(g(r, 'metrics.impressions')), clicks: num(g(r, 'metrics.clicks')), cost: money(g(r, 'metrics.costMicros')),
conv: num(g(r, 'metrics.conversions')), value: num(g(r, 'metrics.conversionsValue')) };
}
function zero(o) { o.impr = 0; o.clicks = 0; o.cost = 0; o.conv = 0; o.value = 0; return o; }
function addMetrics(t, m) { t.impr += m.impr; t.clicks += m.clicks; t.cost += m.cost; t.conv += m.conv; t.value += m.value; return t; }
function derived(m) {
m.ctr = div(m.clicks, m.impr); m.cpc = div(m.cost, m.clicks); m.cpa = div(m.cost, m.conv);
m.roas = div(m.value, m.cost); m.cvr = div(m.conv, m.clicks); return m;
}
var METRIC_HEAD = ['展示', '点击', '点击率', '费用', '平均点击费用', '转化', '转化价值', '每次转化费用', 'ROAS'];
var METRIC_FMT = [FMT.int, FMT.int, FMT.pct, FMT.money, FMT.money, FMT.dec, FMT.money, FMT.money, FMT.ratio];
function metricCells(m) { derived(m); return [m.impr, m.clicks, m.ctr, m.cost, m.cpc, m.conv, m.value, m.cpa, m.roas]; }
// 以 = + - @ 开头的文字会被表格当成公式,前面加 ' 保持为文字
function cleanRow(row) {
return row.map(function (v) {
if (typeof v === 'string' && /^[=+\-@]/.test(v)) return "'" + v;
if (typeof v === 'number' && !isFinite(v)) return '';
return v === undefined || v === null ? '' : v;
});
}
// 写一张表:清空后写表头和数据,先设格式再写值
function write(ss, name, header, data, formats, opt) {
opt = opt || {};
var sh = ss.getSheetByName(name) || ss.insertSheet(name);
sh.clear();
var r0 = 1;
if (opt.title) { sh.getRange(1, 1).setValue(opt.title).setFontWeight('bold'); r0 = 2; }
var max = opt.max || 20000;
if (data.length > max) { log('提示', name, '共 ' + data.length + ' 行,只写入前 ' + max + ' 行'); data = data.slice(0, max); }
var body = data.length ? data.map(cleanRow) : [header.map(function (_, i) { return i === 0 ? (opt.empty || '(没有数据)') : ''; })];
if (formats && data.length) formats.forEach(function (f, i) { if (f) sh.getRange(r0 + 1, i + 1, data.length, 1).setNumberFormat(f); });
sh.getRange(r0, 1, 1, header.length).setValues([header]).setFontWeight('bold').setBackground(HEAD_BG).setFontColor('#ffffff');
sh.getRange(r0 + 1, 1, body.length, header.length).setValues(body);
sh.setFrozenRows(r0);
if (opt.widths) opt.widths.forEach(function (w, i) { if (w) sh.setColumnWidth(i + 1, w); });
if (opt.wrap && data.length) sh.getRange(r0 + 1, 1, data.length, header.length).setWrap(true).setVerticalAlignment('top');
if (opt.colors && data.length) {
var col = opt.colors.col;
sh.getRange(r0 + 1, col + 1, data.length, 1).setBackgrounds(data.map(function (r) { return [opt.colors.fn(r[col], r)]; }));
}
return sh;
}
function levelColor(v) { return v === '高' ? '#fde2e1' : v === '中' ? '#fff1d6' : v === '低' ? '#eef1f5' : null; }
// 行动清单:[优先级, 类别, 对象, 发现, 数据, 建议]
function writeActions(ss, A, emptyText) {
var rank = { '高': 0, '中': 1, '低': 2 };
A.sort(function (a, b) { return (rank[a[0]] === undefined ? 3 : rank[a[0]]) - (rank[b[0]] === undefined ? 3 : rank[b[0]]); });
if (!A.length) A.push(['—', '—', '—', emptyText || '没有发现需要处理的问题', '', '继续定期运行']);
return write(ss, '行动清单', ['优先级', '类别', '对象', '发现', '数据', '建议'], A, null,
{ wrap: true, widths: [70, 90, 300, 240, 260, 440], colors: { col: 0, fn: levelColor } });
}
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(HEAD_BG).setFontColor('#ffffff');
sh.setFrozenRows(1);
}
return sh;
}
// 历史记录表:同一天重复运行时覆盖当天,再追加到末尾;keepDays 天以前的记录删除
function upsertDay(sh, date, rows, keepDays) {
var last = sh.getLastRow();
if (last > 1) {
var v = sh.getRange(2, 1, last - 1, 1).getValues();
var first = -1, count = 0;
for (var i = v.length - 1; i >= 0; i--) { if (dateStr(v[i][0]) === date) { first = i; count++; } else if (count) break; }
if (count) sh.deleteRows(first + 2, count);
}
if (rows.length) {
var start = sh.getLastRow() + 1;
sh.getRange(start, 1, rows.length, 1).setNumberFormat('@');
sh.getRange(start, 1, rows.length, rows[0].length).setValues(rows.map(cleanRow));
}
if (keepDays && sh.getLastRow() > 1) {
var cutoff = Utilities.formatDate(new Date(Date.now() - keepDays * 864e5), tz(), 'yyyy-MM-dd');
var d = sh.getRange(2, 1, sh.getLastRow() - 1, 1).getValues(), n = 0;
while (n < d.length && dateStr(d[n][0]) < cutoff) n++;
if (n) sh.deleteRows(2, n);
}
}
function dateStr(v) {
if (v instanceof Date) return Utilities.formatDate(v, tz(), 'yyyy-MM-dd');
return String(v || '').slice(0, 10);
}
// 隐藏的状态表:保存键值,用于去重提醒、断点续跑等
function stateGet(ss, key) {
var sh = ss.getSheetByName('_状态'); if (!sh || sh.getLastRow() < 1) return '';
var v = sh.getRange(1, 1, sh.getLastRow(), 2).getValues();
for (var i = 0; i < v.length; i++) if (v[i][0] === key) return String(v[i][1]);
return '';
}
function stateSet(ss, key, val) {
var sh = ss.getSheetByName('_状态') || ss.insertSheet('_状态');
var n = sh.getLastRow(), v = n ? sh.getRange(1, 1, n, 1).getValues() : [];
for (var i = 0; i < v.length; i++) if (v[i][0] === key) { sh.getRange(i + 1, 2).setNumberFormat('@').setValue(String(val)); sh.hideSheet(); return; }
sh.getRange(n + 1, 1, 1, 2).setNumberFormat('@').setValues([[key, String(val)]]);
sh.hideSheet();
}
function writeGuide(ss, title, version, rows) {
var acct = AdsApp.currentAccount();
var all = [[title + '(' + version + ')', ''],
['账户', acct.getName() + '(' + acct.getCustomerId() + ')'],
['运行时间', Utilities.formatDate(STARTED, tz(), 'yyyy-MM-dd HH:mm') + (isPreview() ? '(预览模式)' : '')],
['', '']].concat(rows).concat([['', ''], ['脚本库', 'https://berightdigital.com/google-ads-scripts/']]);
var sh = ss.getSheetByName('使用说明') || ss.insertSheet('使用说明');
sh.clear();
sh.getRange(1, 1, all.length, 2).setValues(all.map(cleanRow));
sh.getRange(1, 1).setFontSize(14).setFontWeight('bold');
sh.setColumnWidth(1, 220); sh.setColumnWidth(2, 720);
sh.getRange(1, 2, all.length, 1).setWrap(true);
}
function writeLog(ss) {
log('信息', '完成', '用时 ' + Math.round((new Date() - STARTED) / 1000) + ' 秒');
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 orderSheets(ss, order) {
order.forEach(function (n, i) {
var sh = ss.getSheetByName(n);
if (sh) { ss.setActiveSheet(sh); ss.moveActiveSheet(i + 1); }
});
ss.getSheets().forEach(function (s) { if (/^_/.test(s.getName())) s.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(order[1] || order[0]);
if (first) ss.setActiveSheet(first);
}
// 发 HTML 邮件:rows 为二维数组(第一行是表头)
function mail(subject, intro, rows, ss) {
if (!CONFIG.EMAIL) return;
var h = function (s) { return String(s === undefined || s === null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); };
var table = rows && rows.length > 1 ? '<table cellpadding="6" cellspacing="0" style="border-collapse:collapse;border:1px solid #ddd;font-size:13px">' +
rows.map(function (r, i) {
return '<tr style="' + (i === 0 ? 'background:' + HEAD_BG + ';color:#fff' : 'border-top:1px solid #ddd;vertical-align:top') + '">' +
r.map(function (c, j) { var bg = i > 0 && j === 0 ? levelColor(c) : null; return (i === 0 ? '<th>' : '<td' + (bg ? ' style="background:' + bg + '"' : '') + '>') + h(typeof c === 'number' ? r2(c) : c) + (i === 0 ? '</th>' : '</td>'); }).join('') + '</tr>';
}).join('') + '</table>' : '';
var html = '<div style="font-family:Arial,sans-serif;font-size:13px;color:#222"><p>' + h(intro).replace(/\n/g, '<br>') + '</p>' + table +
(ss ? '<p><a href="' + ss.getUrl() + '">打开完整报告</a></p>' : '') + '</div>';
var plain = intro + '\n\n' + (rows || []).slice(1).map(function (r) { return r.join(' | '); }).join('\n') + (ss ? '\n\n完整报告:' + ss.getUrl() : '');
MailApp.sendEmail({ to: CONFIG.EMAIL, subject: subject, body: plain, htmlBody: html });
log('信息', '邮件', '已发送到 ' + CONFIG.EMAIL);
}
搭配使用的脚本
- 账户变更提醒脚本:谁改了广告,第一时间知道。
- 质量得分追踪脚本:否定掉无关搜索后,预期点击率通常会上升,可以用它追踪变化。
- PMax 健康检查脚本:PMax 的渠道、素材和商品表现。
- 更多脚本见 Google Ads 脚本库。
毕瑞出海BerightDigital微信扫码关注,视频和文章同步更新