质量得分(Quality Score)是 Google 搜索广告里最常被看、也最常被误读的指标。后台只显示「现在」的分数,昨天是多少、上个月是多少,Google 不给你看。改了广告、换了落地页之后分数到底涨没涨,也就只能凭感觉。
这篇教程介绍一个免费的 Google Ads 脚本:每天自动记录所有搜索关键字的质量得分和三个组成部分,生成趋势图,发现得分下降的关键字,并按花费排出该先改哪里。脚本是我们在 Adsscripts.com 上一个经典脚本的基础上重新编写的中文升级版,复制粘贴就能用,不需要会写代码。
如果你还不太了解质量得分是怎么算的,建议先看 Google Ads 质量得分详解。
为什么要追踪质量得分
- 后台没有历史记录:关键字列表里的「质量得分(历史)」列只能按日期范围看一个值,看不到每天的变化,也看不到账户整体走势。
- 改动效果难以验证:重写广告、拆分广告组、优化落地页之后,需要对比前后的得分才能知道有没有用。
- 问题发现得太晚:网站改版、落地页 404、加载变慢都可能让着陆页体验掉分,等到 CPC 上涨才发现已经晚了。
- 看平均分会被误导:100 个几乎不花钱的长尾词把平均分拉高,而真正花钱的那几个词可能只有 3 分。所以要看按费用加权的得分。
原版脚本做了什么,有哪些不足
原版是 Martin Roettgerding 写的 Quality Score Tracker,后来由 Tibbe van Asten 维护更新到 v3,发布在 Adsscripts.com。它的思路很好:每天运行一次,把账户的平均质量得分和各分数段的关键字数写进 Google 表格,时间长了就有了趋势。感谢两位作者的分享。
我们在实际使用和读代码的过程中发现了这些可以改进的地方:
| 问题 | 影响 |
|---|---|
| 使用旧版 AdWordsApp 和 AWQL 报告(KEYWORDS_PERFORMANCE_REPORT) | Google 已经把脚本迁移到新版 AdsApp 和 GAQL,旧接口随时可能停止支持 |
| 只记录总分,不记录三个组成部分 | 知道分数降了,但不知道是点击率、广告相关性还是着陆页出了问题 |
| 只按展示次数加权 | 展示多但不花钱的词(例如品牌词)会掩盖花钱多的低分词 |
| 需要先复制一个外部模板表格 | 模板失效或被改动后脚本就会出错 |
| 每次运行都向一个 Universal Analytics 地址发送统计请求(http,非加密) | UA 已经在 2023 年停止处理数据,请求没有意义,也不应该在用户不知情时发送 |
| 「只统计在投关键字」的设置实际不生效 | 已暂停的关键字也被算进去 |
| 数据量大时的备用逻辑引用了未声明的变量 | 大账户可能直接报错 |
| 每个日期占一列的宽表结构 | 用久了列数很多,难以筛选和做图 |
| 只记录,不诊断 | 看到数据后还得自己去后台一个个找关键字 |
升级版 v2 改了什么
| 项目 | 原版 v3 | 升级版 v2 |
|---|---|---|
| 接口 | AdWordsApp + AWQL | AdsApp + GAQL |
| 三个组成部分 | 不记录 | 每个关键字都记录预期点击率、广告相关性、着陆页体验 |
| 加权方式 | 展示加权 | 不加权、展示加权、费用加权三种 |
| 表格 | 需要复制模板 | 自动新建,自动建好所有工作表和图表 |
| 第三方请求 | 有(UA 统计) | 没有,数据只写进你自己的 Google 表格 |
| 得分变化 | 无 | 和上一次记录对比,列出上升和下降的关键字 |
| 诊断 | 无 | 关键字诊断表,按「费用 × 差距」排优先级,并给出修改建议 |
| 汇总 | 账户层级 | 账户、广告系列、广告组三个层级 |
| 行动清单 | 无 | 自动列出该先处理的问题,分高、中、低三级 |
| 邮件 | 无 | 有关键字掉分时发邮件提醒(可选) |
| 历史回填 | 无 | 第一次运行时尝试回填过去 90 天(账户支持时) |
| 出错处理 | 一步出错整个脚本停止 | 每一步单独处理,错误写进「运行日志」,其他表照常生成 |
这个脚本只读取数据,不会修改账户里的任何东西:不改出价、不暂停关键字、不改广告。授权时 Google 会提示脚本需要访问 Google 表格和发送邮件,这是写报告和发提醒用的。
安装步骤(10 分钟)
- 登录 Google Ads,点击左侧「工具」→「批量操作」→「脚本」。
- 点击蓝色「+」按钮,选择「新建脚本」,给脚本起个名字,例如「质量得分追踪」。
- 删掉编辑器里默认的几行代码,把本文末尾的完整代码全部粘贴进去。
- 点击「授权」,用有这个 Google Ads 账户权限的 Google 账号登录并允许访问。
- 点击「预览」。第一次运行会自动新建一个 Google 表格,等运行结束后,在下方「日志」里找到以「★ 新建的表格」开头的那一行,复制表格网址。
- 把网址粘贴到代码开头的
SPREADSHEET_URL: ''两个引号中间,保存。不填的话每次运行都会新建一个表格。 - 回到脚本列表,把运行频率设为「每天」,时间选一个固定的时段,例如早上 6 点。
预览模式也会正常写入表格(脚本本来就不改账户),所以预览一次就能看到完整报告。
设置说明
所有设置都在代码开头的 CONFIG 部分,一般只需要改前几项。
| 设置 | 默认值 | 说明 |
|---|---|---|
| SPREADSHEET_URL | 空 | 报告表格网址,第一次运行后填入 |
| CAMPAIGN_NAME_CONTAINS | 空 | 只分析名称包含这段文字的广告系列 |
| CAMPAIGN_NAME_EXCLUDES | 空 | 排除名称包含这段文字的广告系列,例如填 Brand 排除品牌词系列 |
| DAYS | 30 | 展示、点击、费用的统计天数。质量得分本身是当天的值,不受影响 |
| GOOGLE_SEARCH_ONLY | true | 只统计 Google 搜索,不含搜索网络合作伙伴 |
| LOW_QS | 4 | 质量得分小于等于这个值算低分 |
| DROP_ALERT | 2 | 关键字比上次下降至少几分就提醒 |
| TARGET_QS | 7 | 优先级分数里的目标分数 |
| SAVE_KEYWORD_HISTORY | true | 是否保存每个关键字每天的得分 |
| KEYWORD_HISTORY_DAYS | 120 | 关键字历史保留天数,超过自动删除 |
| BACKFILL_DAYS | 90 | 第一次运行时尝试回填的天数,0 表示不回填 |
| MAX_ROWS | 5000 | 关键字诊断表最多写入的行数 |
| 空 | 提醒邮箱,多个用英文逗号分隔 | |
| EMAIL_ALWAYS | false | true 表示每天都发;false 表示只在有关键字掉分时发 |
怎么看报告
表格打开时默认停在「行动清单」。建议每周花 15 分钟按下面的顺序看一遍。
行动清单:先看这里
脚本把发现的问题按优先级排好,每一行有「对象」「发现」「数据」「建议」四栏。常见的几类:
- 高:账户费用加权质量得分比上一次下降 0.3 分以上;某个关键字下降了 2 分以上;低分关键字花费较多。
- 中:一个广告组一半以上的费用花在低分关键字上;账户里某个组成部分有 25% 以上的展示处在「低于平均」。
- 低:较多费用花在还没有质量得分的关键字上;第一次运行的说明。
仪表盘:看整体水平和趋势
- 费用加权质量得分:最重要的一个数。它回答的是「我花出去的钱,平均处在几分的关键字上」。
- 展示加权和不加权的平均分:用来对比。如果不加权的平均分明显高于费用加权的,说明高分词大多不花钱,花钱的反而是低分词。
- 低分关键字费用占比:质量得分 ≤ 4 的关键字花掉了多少比例的预算。
- 三张图:各分数段的费用占比、三个组成部分的分布、每日加权得分趋势(运行两天以上才会出现)。
关键字诊断:找到具体要改的词
每个有展示的关键字一行,包括得分、上次得分、变化、三个组成部分、效果数据和建议,按优先级分数从高到低排序。
优先级分数 = 费用 ×(目标分数 7 − 当前分数)。一个花了 500 元、3 分的词,优先级是 2000;一个花了 20 元、2 分的词,优先级只有 100。先改前者收益大得多。
「建议」栏根据哪个组成部分「低于平均」给出对应的修改方向,三项都低于平均的就三条都列出来。
广告组汇总和广告系列汇总
每个广告组(广告系列)的费用加权得分、低分关键字数量和费用占比,以及最常见的短板。如果一个广告组的短板是「广告相关性」,通常说明组里的关键字主题太杂,需要拆分。
得分变化
和上一次记录相比得分发生变化的关键字,下降的排在前面。同一天多次运行时,对比的仍然是前一天的记录,不会因为重复运行而显示「没有变化」。
每日汇总和关键字历史
- 每日汇总:一天一行,记录账户的各种加权得分、低分占比、三个组成部分的低于平均占比和 1–10 分的关键字数量。最后一列「来源」标明是每天记录还是历史回填。
- 关键字历史:每个关键字每天一行,方便用筛选或数据透视表看某个词的长期走势。超过保留天数的旧数据会自动删除。
运行日志
每一步是否成功。某张表没有更新时,先看这里的「错误」行。
三个组成部分分别怎么改
质量得分由预期点击率、广告相关性和着陆页体验三部分决定,每部分有「高于平均」「平均」「低于平均」三档。改分就是把「低于平均」的那一项提上去。
预期点击率低
- 标题里直接使用关键字或非常接近的说法,并写出具体卖点(价格、免运费、库存、评分)。
- 补齐附加信息:附加链接、宣传信息、结构化摘要、图片。
- 检查搜索字词报告,用否定关键字去掉不相关的流量;广泛匹配的词可以考虑改成词组或完全匹配。
- 参考:Google Ads 点击率低怎么办,也可以用我们的 RSA 广告文案生成器 批量写标题。
广告相关性低
- 把主题不同的关键字拆到不同的广告组。一个广告组只放一个主题,例如「男士登山鞋」和「防水登山鞋」分开。
- 每个广告组的广告标题直接使用组内的核心关键字。
- 不要把品牌词、竞品词、通用词放在同一个组里。
着陆页体验低
- 关键字要落到最相关的页面:搜商品的落到商品页或分类页,不要统一落到首页。
- 检查手机端加载速度和 Core Web Vitals,参考 LCP、INP、CLS 优化指南。
- 首屏就要能看到搜索的东西,价格、运费、退换政策写清楚。
- 可以用 落地页转化检查工具 逐项检查。
改完之后不要急着看结果。质量得分一般需要几天到两周才会更新,这正是每天记录的意义:两周后在「每日汇总」和「关键字历史」里对比改动前后的数据。
常见问题
为什么很多关键字显示「无得分」?
质量得分需要足够的搜索量才会计算。新加的关键字或搜索量很少的长尾词通常没有得分,这很正常。这些词先按转化和 ROAS 判断好坏即可。
质量得分越高越好吗?
质量得分是诊断工具,Google 在竞价时使用的是每次搜索实时计算的质量,后台的 1–10 分只是一个汇总参考。一个 5 分但 ROAS 很高的关键字,没有必要为了提分去大改。脚本的优先级也是按花费排的,而不是单纯按低分排。
第一次运行没有回填历史数据?
回填依赖 metrics.historical_quality_score 字段,部分账户或脚本版本查询不到。查询失败时脚本会在日志里注明并跳过,不影响之后每天的正常记录。回填的数据只有账户层级的汇总,没有三个组成部分,也不参与掉分提醒。
账户很大,会不会超出限制?
- Google 表格单个文件最多 1000 万个单元格。关键字历史会按 KEYWORD_HISTORY_DAYS 自动删除旧数据,接近上限时日志会提示。关键字很多时可以把保留天数调小,或者把 SAVE_KEYWORD_HISTORY 设为 false。
- 单个脚本最多运行 30 分钟。一般几万个关键字在几分钟内就能完成。
可以在 MCC 经理账户里运行吗?
这个版本是为单个账户写的,需要在每个账户里分别安装。多个账户可以共用同一个表格吗?不建议,各账户的快照会互相覆盖,请每个账户用自己的表格。
为什么不包含效果最大化广告系列(PMax)?
PMax 没有关键字,也就没有质量得分。PMax 的检查可以用我们的 PMax 健康检查脚本。
不想写脚本,有没有现成的工具?
如果管理的账户很多,可以考虑 Optmyzr 这类付费工具,自带质量得分追踪和多账户报告。只有一两个账户的话,这个免费脚本已经够用。
完整代码
点击代码框右上角可以复制全部代码。复制后粘贴到 Google Ads 脚本编辑器即可,记得在第一次运行后填入表格网址。
/**
* 质量得分追踪与诊断脚本 v2.0(毕瑞出海中文版)
* ------------------------------------------------------------
* 作用:每天记录搜索广告关键字的质量得分和三个组成部分
* (预期点击率、广告相关性、着陆页体验),
* 画出账户质量得分的变化趋势,找出得分下降和拖后腿的关键字,
* 按花费排出优化优先级,并生成「行动清单」。
*
* 思路参考 Martin Roettgerding / Tibbe van Asten 的 Quality Score Tracker v3,代码为重新编写:
* - 使用 GAQL(AdsApp.search),不再使用旧版 AWQL 报告;
* - 记录三个组成部分,并按花费加权,而不只是展示加权;
* - 不依赖外部模板表格,不向任何第三方发送数据;
* - 新增关键字诊断、广告组汇总、得分变化提醒、行动清单、邮件通知;
* - 第一次运行时尝试回填过去的历史得分(账户支持时)。
*
* 使用方法和每个工作表的解读:https://berightdigital.com/quality-score-tracker-script/
* ------------------------------------------------------------
*/
// ======================= 设置(只需要改这里) =======================
var CONFIG = {
// Google 表格网址。留空 = 第一次运行时自动新建,网址打印在「日志」里,复制回这里。
SPREADSHEET_URL: '',
// 只分析名称包含这段文字的搜索广告系列(留空 = 全部)
CAMPAIGN_NAME_CONTAINS: '',
// 排除名称包含这段文字的广告系列(留空 = 不排除),例如 'Brand'
CAMPAIGN_NAME_EXCLUDES: '',
// 展示、点击、费用等效果数据的时间范围(天,截至昨天)。质量得分本身是当前值,不受影响。
DAYS: 30,
// true = 只统计 Google 搜索,不含搜索网络合作伙伴(质量得分只反映 Google 搜索)
GOOGLE_SEARCH_ONLY: true,
// 质量得分 ≤ 这个值算「低分」
LOW_QS: 4,
// 质量得分比上一次记录下降至少这么多分,就提醒
DROP_ALERT: 2,
// 优先级计算的目标分数:优先级 = 费用 × (目标分数 − 当前分数)
TARGET_QS: 7,
// 是否保存每个关键字每天的得分(数据量大时可以关掉,只保留每日汇总)
SAVE_KEYWORD_HISTORY: true,
// 关键字历史保留多少天,超过的自动删除,避免表格超过 1000 万格的上限
KEYWORD_HISTORY_DAYS: 120,
// 第一次运行时回填过去多少天的账户汇总(0 = 不回填)。需要账户支持历史质量得分数据。
BACKFILL_DAYS: 90,
// 「关键字诊断」最多写入多少行(按优先级排序)
MAX_ROWS: 5000,
// 有下降提醒时发邮件到这个地址(留空 = 不发)。多个邮箱用英文逗号分隔。
EMAIL: '',
// true = 每次运行都发邮件;false = 只在有得分下降时发
EMAIL_ALWAYS: false
};
// ======================= 设置结束,下面的代码一般不需要改 =======================
var VERSION = 'v2.0';
var RUN_LOG = [];
var FMT = { money: '#,##0.00', int: '#,##0', pct: '0.0%', qs: '0.00', dec: '#,##0.00' };
var COMP_CN = { ABOVE_AVERAGE: '高于平均', AVERAGE: '平均', BELOW_AVERAGE: '低于平均', UNKNOWN: '', UNSPECIFIED: '' };
var MATCH_CN = { EXACT: '完全匹配', PHRASE: '词组匹配', BROAD: '广泛匹配' };
var COMPS = [
{ key: 'ctr', name: '预期点击率' },
{ key: 'rel', name: '广告相关性' },
{ key: 'lp', name: '着陆页体验' }
];
var TIPS = {
ctr: '预期点击率低:标题里放入关键字和具体卖点,补齐附加信息;用否定关键字和更窄的匹配去掉不相关的搜索',
rel: '广告相关性低:把主题不同的关键字拆到不同广告组,让每组广告的标题直接使用组内关键字',
lp: '着陆页体验低:让关键字落到对应的商品或分类页,检查手机端速度、首屏内容和价格运费信息'
};
function main() {
var started = new Date();
var acct = AdsApp.currentAccount();
var tz = acct.getTimeZone();
var today = Utilities.formatDate(started, tz, 'yyyy-MM-dd');
log('信息', '开始', '账户:' + acct.getName() + '(' + acct.getCustomerId() + '),版本 ' + VERSION + ',日期 ' + today);
var ss = openSpreadsheet(acct);
// 1. 读取当前所有关键字的质量得分和效果数据
var kws = fetchKeywords();
log('信息', '关键字', kws.length + ' 个有展示的搜索关键字,其中 ' +
kws.filter(function (k) { return k.qs > 0; }).length + ' 个有质量得分');
// 2. 和上一期对比
var base = safe('读取对比基准', function () { return loadBaseline(ss, today); }) || { date: '', map: {} };
kws.forEach(function (k) {
var b = base.map[k.key];
k.prevQs = b ? b.qs : '';
k.change = (b && b.qs > 0 && k.qs > 0) ? k.qs - b.qs : '';
});
// 3. 汇总
var summary = summarize(kws);
// 4. 写各个工作表
safe('关键字诊断', function () { writeDiagnosis(ss, kws); });
var ags = safe('广告组汇总', function () { return writeGroups(ss, kws, 'ag'); }) || [];
safe('广告系列汇总', function () { writeGroups(ss, kws, 'camp'); });
safe('得分变化', function () { writeChanges(ss, kws, base.date); });
safe('历史回填', function () { backfill(ss, today); });
safe('每日汇总', function () { appendDaily(ss, today, summary); });
if (CONFIG.SAVE_KEYWORD_HISTORY) safe('关键字历史', function () { appendKeywordHistory(ss, today, kws); });
safe('保存快照', function () { saveSnapshot(ss, today, kws); });
safe('仪表盘', function () { writeDashboard(ss, today, summary, base.date); });
var actions = safe('行动清单', function () { return buildActions(ss, kws, summary, ags, base.date); }) || [];
safe('使用说明', function () { writeGuide(ss, acct, started); });
log('信息', '完成', '用时 ' + Math.round((new Date() - started) / 1000) + ' 秒,表格:' + ss.getUrl());
writeLog(ss);
orderSheets(ss);
var drops = kws.filter(function (k) { return k.change !== '' && k.change <= -CONFIG.DROP_ALERT; }).length;
if (CONFIG.EMAIL && (CONFIG.EMAIL_ALWAYS || drops > 0)) {
safe('邮件', function () { sendEmail(acct, ss, today, summary, actions, drops); });
}
}
// ======================= 读取数据 =======================
function fetchKeywords() {
var sel = 'SELECT campaign.id, campaign.name, ad_group.id, ad_group.name, ad_group_criterion.criterion_id, ' +
'ad_group_criterion.keyword.text, ad_group_criterion.keyword.match_type, ' +
'ad_group_criterion.quality_info.quality_score, ad_group_criterion.quality_info.creative_quality_score, ' +
'ad_group_criterion.quality_info.post_click_quality_score, ad_group_criterion.quality_info.search_predicted_ctr, ' +
'metrics.impressions, metrics.clicks, metrics.cost_micros, metrics.conversions, metrics.conversions_value';
var where = ' FROM keyword_view WHERE ' + range(CONFIG.DAYS) +
' AND campaign.advertising_channel_type = "SEARCH" AND campaign.status = "ENABLED" AND ad_group.status = "ENABLED"' +
' AND ad_group_criterion.status = "ENABLED" AND ad_group_criterion.negative = FALSE' + nameFilters();
var rows;
if (CONFIG.GOOGLE_SEARCH_ONLY) {
try {
rows = runQuery(sel + ', segments.ad_network_type' + where + ' AND segments.ad_network_type = "SEARCH"');
} catch (e) {
log('提示', '关键字', '按网络筛选失败,改为包含所有搜索网络:' + e.message);
rows = runQuery(sel + where);
}
} else {
rows = runQuery(sel + where);
}
var map = {};
rows.forEach(function (r) {
var key = g(r, 'adGroup.id') + '~' + g(r, 'adGroupCriterion.criterionId');
if (!map[key]) {
var qi = 'adGroupCriterion.qualityInfo.';
map[key] = zero({
key: key, campaign: g(r, 'campaign.name'), group: g(r, 'adGroup.name'),
text: g(r, 'adGroupCriterion.keyword.text'), match: g(r, 'adGroupCriterion.keyword.matchType'),
qs: num(g(r, qi + 'qualityScore')),
ctr: g(r, qi + 'searchPredictedCtr'), rel: g(r, qi + 'creativeQualityScore'), lp: g(r, qi + 'postClickQualityScore')
});
}
addMetrics(map[key], metricsOf(r));
});
var list = values(map);
list.forEach(function (k) {
k.weak = COMPS.filter(function (c) { return k[c.key] === 'BELOW_AVERAGE'; }).map(function (c) { return c.key; });
k.priority = k.qs > 0 ? k.cost * Math.max(0, CONFIG.TARGET_QS - k.qs) : 0;
});
return list;
}
function summarize(kws) {
var s = { kw: 0, kwQs: 0, impr: 0, cost: 0, clicks: 0, conv: 0, value: 0,
sumQs: 0, imprQs: 0, imprW: 0, costQs: 0, costW: 0,
lowCost: 0, noQsCost: 0, dist: {}, comp: {} };
for (var q = 1; q <= 10; q++) s.dist[q] = { n: 0, impr: 0, cost: 0 };
s.dist['无'] = { n: 0, impr: 0, cost: 0 };
COMPS.forEach(function (c) { s.comp[c.key] = { ABOVE_AVERAGE: 0, AVERAGE: 0, BELOW_AVERAGE: 0, imprAll: 0 }; });
kws.forEach(function (k) {
s.kw++; s.impr += k.impr; s.cost += k.cost; s.clicks += k.clicks; s.conv += k.conv; s.value += k.value;
var d = s.dist[k.qs > 0 ? k.qs : '无']; d.n++; d.impr += k.impr; d.cost += k.cost;
if (k.qs > 0) {
s.kwQs++; s.sumQs += k.qs;
s.imprQs += k.qs * k.impr; s.imprW += k.impr;
s.costQs += k.qs * k.cost; s.costW += k.cost;
if (k.qs <= CONFIG.LOW_QS) s.lowCost += k.cost;
COMPS.forEach(function (c) {
var v = k[c.key];
if (s.comp[c.key][v] !== undefined) { s.comp[c.key][v] += k.impr; s.comp[c.key].imprAll += k.impr; }
});
} else {
s.noQsCost += k.cost;
}
});
s.avgQs = s.kwQs ? s.sumQs / s.kwQs : '';
s.imprWQs = s.imprW ? s.imprQs / s.imprW : '';
s.costWQs = s.costW ? s.costQs / s.costW : '';
s.lowShare = s.costW ? s.lowCost / s.costW : '';
COMPS.forEach(function (c) {
var x = s.comp[c.key];
x.belowShare = x.imprAll ? x.BELOW_AVERAGE / x.imprAll : '';
x.aboveShare = x.imprAll ? x.ABOVE_AVERAGE / x.imprAll : '';
});
return s;
}
// ======================= 写入报告 =======================
function writeDiagnosis(ss, kws) {
var list = kws.slice().sort(function (a, b) { return b.priority - a.priority || b.cost - a.cost; });
var header = ['广告系列', '广告组', '关键字', '匹配方式', '质量得分', '上次得分', '变化', '预期点击率', '广告相关性',
'着陆页体验', '展示', '点击', '点击率', '费用', '转化', '转化价值', 'ROAS', '优先级分数', '建议'];
var data = list.map(function (k) {
return [k.campaign, k.group, k.text, cn(MATCH_CN, k.match), k.qs || '无', k.prevQs === '' ? '' : (k.prevQs || '无'),
k.change, cn(COMP_CN, k.ctr), cn(COMP_CN, k.rel), cn(COMP_CN, k.lp), k.impr, k.clicks, div(k.clicks, k.impr),
k.cost, k.conv, k.value, div(k.value, k.cost), k.priority, adviceFor(k)];
});
data = cap(data, '关键字诊断');
var sh = write(ss, '关键字诊断', header, data,
['', '', '', '', '', '', '+0;-0;0', '', '', '', FMT.int, FMT.int, FMT.pct, FMT.money, FMT.dec, FMT.money, FMT.qs, FMT.int, '']);
if (data.length) {
sh.getRange(2, 5, data.length, 1).setBackgrounds(data.map(function (r) { return [qsColor(r[4])]; }));
[8, 9, 10].forEach(function (col) {
sh.getRange(2, col, data.length, 1).setBackgrounds(data.map(function (r) {
return [r[col - 1] === '低于平均' ? '#fde2e1' : r[col - 1] === '高于平均' ? '#e3f4e8' : null];
}));
});
}
}
function adviceFor(k) {
if (!(k.qs > 0)) return k.cost > 0 ? '暂无质量得分(数据不足),先看转化和费用' : '';
if (!k.weak.length) return k.qs <= CONFIG.LOW_QS ? '三项都不低于平均但总分偏低,整体提升广告和着陆页' : '';
return k.weak.map(function (w) { return TIPS[w]; }).join(';');
}
function writeGroups(ss, kws, level) {
var name = level === 'ag' ? '广告组汇总' : '广告系列汇总';
var map = {};
kws.forEach(function (k) {
var key = level === 'ag' ? k.campaign + '||' + k.group : k.campaign;
if (!map[key]) map[key] = zero({ campaign: k.campaign, group: level === 'ag' ? k.group : '', n: 0, nQs: 0,
qsCost: 0, wCost: 0, qsImpr: 0, wImpr: 0, low: 0, lowCost: 0, below: { ctr: 0, rel: 0, lp: 0 } });
var m = map[key];
addMetrics(m, k); m.n++;
if (k.qs > 0) {
m.nQs++; m.qsCost += k.qs * k.cost; m.wCost += k.cost; m.qsImpr += k.qs * k.impr; m.wImpr += k.impr;
if (k.qs <= CONFIG.LOW_QS) { m.low++; m.lowCost += k.cost; }
k.weak.forEach(function (w) { m.below[w] += k.cost || 0.0001; });
}
});
var list = values(map);
list.forEach(function (m) {
m.costWQs = m.wCost ? m.qsCost / m.wCost : (m.wImpr ? m.qsImpr / m.wImpr : '');
m.lowShare = m.wCost ? m.lowCost / m.wCost : '';
var top = COMPS.slice().sort(function (a, b) { return m.below[b.key] - m.below[a.key]; })[0];
m.mainWeak = m.below[top.key] > 0 ? top.key : '';
});
list.sort(function (a, b) { return b.lowCost - a.lowCost || b.cost - a.cost; });
var header = (level === 'ag' ? ['广告系列', '广告组'] : ['广告系列']).concat(
['关键字数', '有得分的关键字', '费用加权质量得分', '展示加权质量得分', '低分关键字数', '低分关键字费用占比',
'最常见的短板', '费用', '展示', '点击', '转化', '转化价值', 'ROAS']);
var data = list.map(function (m) {
var row = level === 'ag' ? [m.campaign, m.group] : [m.campaign];
return row.concat([m.n, m.nQs, m.costWQs, div(m.qsImpr, m.wImpr), m.low, m.lowShare,
m.mainWeak ? compName(m.mainWeak) : '', m.cost, m.impr, m.clicks, m.conv, m.value, div(m.value, m.cost)]);
});
var off = level === 'ag' ? 2 : 1;
var fm = (level === 'ag' ? ['', ''] : ['']).concat([FMT.int, FMT.int, FMT.qs, FMT.qs, FMT.int, FMT.pct, '',
FMT.money, FMT.int, FMT.int, FMT.dec, FMT.money, FMT.qs]);
var sh = write(ss, name, header, data, fm);
if (data.length) sh.getRange(2, off + 3, data.length, 1).setBackgrounds(data.map(function (r) { return [qsColor(r[off + 2])]; }));
return list;
}
function writeChanges(ss, kws, baseDate) {
var list = kws.filter(function (k) { return k.change !== '' && k.change !== 0; })
.sort(function (a, b) { return a.change - b.change || b.cost - a.cost; });
var header = ['广告系列', '广告组', '关键字', '上次得分', '当前得分', '变化', '预期点击率', '广告相关性', '着陆页体验', '近期费用'];
var data = list.map(function (k) {
return [k.campaign, k.group, k.text, k.prevQs, k.qs, k.change, cn(COMP_CN, k.ctr), cn(COMP_CN, k.rel), cn(COMP_CN, k.lp), k.cost];
});
var sh = write(ss, '得分变化', header, data, ['', '', '', '', '', '+0;-0;0', '', '', '', FMT.money]);
if (data.length) {
sh.getRange(2, 6, data.length, 1).setBackgrounds(data.map(function (r) { return [r[5] < 0 ? '#fde2e1' : '#e3f4e8']; }));
}
if (!baseDate) log('提示', '得分变化', '第一次运行,还没有可对比的记录,明天起会显示变化。');
else log('信息', '得分变化', '对比基准日期 ' + baseDate + ',变化的关键字 ' + list.length + ' 个');
}
// 每日汇总:一天一行,同一天重复运行会覆盖当天
var DAILY_HEADER = ['日期', '关键字数', '有得分的关键字', '平均质量得分', '展示加权质量得分', '费用加权质量得分', '低分关键字费用占比',
'预期点击率低于平均(展示占比)', '广告相关性低于平均(展示占比)', '着陆页体验低于平均(展示占比)',
'QS1', 'QS2', 'QS3', 'QS4', 'QS5', 'QS6', 'QS7', 'QS8', 'QS9', 'QS10', '展示', '费用', '来源'];
function appendDaily(ss, today, s) {
var sh = ensureSheet(ss, '每日汇总', DAILY_HEADER);
var row = [today, s.kw, s.kwQs, s.avgQs, s.imprWQs, s.costWQs, s.lowShare,
s.comp.ctr.belowShare, s.comp.rel.belowShare, s.comp.lp.belowShare];
for (var q = 1; q <= 10; q++) row.push(s.dist[q].n);
row.push(s.impr, s.cost, '每日记录');
upsertByDate(sh, today, [row]);
var n = sh.getLastRow() - 1;
sh.getRange(2, 4, n, 3).setNumberFormat(FMT.qs);
sh.getRange(2, 7, n, 4).setNumberFormat(FMT.pct);
sh.getRange(2, 21, n, 1).setNumberFormat(FMT.int);
sh.getRange(2, 22, n, 1).setNumberFormat(FMT.money);
}
// 第一次运行时,尝试用历史质量得分数据回填过去 N 天的账户汇总
function backfill(ss, today) {
if (!CONFIG.BACKFILL_DAYS) return;
var sh = ss.getSheetByName('每日汇总');
if (sh && sh.getLastRow() > 1) return; // 已经有记录,不需要回填
var where = ' FROM keyword_view WHERE ' + range(CONFIG.BACKFILL_DAYS) +
' AND campaign.advertising_channel_type = "SEARCH" AND campaign.status = "ENABLED" AND ad_group.status = "ENABLED"' +
' AND ad_group_criterion.status = "ENABLED" AND ad_group_criterion.negative = FALSE' + nameFilters();
var rows, weighted = true;
try {
rows = runQuery('SELECT segments.date, ad_group_criterion.criterion_id, metrics.historical_quality_score, metrics.impressions, metrics.cost_micros' + where);
} catch (e) {
try {
weighted = false;
rows = runQuery('SELECT segments.date, ad_group_criterion.criterion_id, metrics.historical_quality_score' + where);
} catch (e2) {
log('提示', '历史回填', '账户不支持历史质量得分查询,跳过回填(不影响每天的记录):' + e2.message);
return;
}
}
var days = {};
rows.forEach(function (r) {
var q = num(g(r, 'metrics.historicalQualityScore')); if (!(q > 0)) return;
var d = g(r, 'segments.date'); if (!d || d >= today) return;
var x = days[d] || (days[d] = { n: 0, sum: 0, iq: 0, iw: 0, cq: 0, cw: 0, dist: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], impr: 0, cost: 0 });
var im = weighted ? num(g(r, 'metrics.impressions')) : 0, co = weighted ? money(g(r, 'metrics.costMicros')) : 0;
x.n++; x.sum += q; x.iq += q * im; x.iw += im; x.cq += q * co; x.cw += co; x.dist[Math.round(q)]++; x.impr += im; x.cost += co;
});
var dates = Object.keys(days).sort();
if (!dates.length) { log('提示', '历史回填', '没有查到历史质量得分数据,跳过回填'); return; }
sh = ensureSheet(ss, '每日汇总', DAILY_HEADER);
var out = dates.map(function (d) {
var x = days[d];
var row = [d, x.n, x.n, x.sum / x.n, x.iw ? x.iq / x.iw : '', x.cw ? x.cq / x.cw : '', '', '', '', ''];
for (var q = 1; q <= 10; q++) row.push(x.dist[q]);
row.push(weighted ? x.impr : '', weighted ? x.cost : '', '历史回填');
return row;
});
sh.getRange(2, 1, out.length, 1).setNumberFormat('@');
sh.getRange(2, 1, out.length, DAILY_HEADER.length).setValues(out.map(cleanRow));
log('信息', '历史回填', '回填了 ' + dates.length + ' 天的账户汇总(' + dates[0] + ' 至 ' + dates[dates.length - 1] + ')');
}
var KH_HEADER = ['日期', '关键字键', '广告系列', '广告组', '关键字', '匹配方式', '质量得分', '预期点击率', '广告相关性', '着陆页体验', '展示', '费用'];
function appendKeywordHistory(ss, today, kws) {
var sh = ensureSheet(ss, '关键字历史', KH_HEADER);
var rows = kws.map(function (k) {
return [today, k.key, k.campaign, k.group, k.text, cn(MATCH_CN, k.match), k.qs || '', cn(COMP_CN, k.ctr),
cn(COMP_CN, k.rel), cn(COMP_CN, k.lp), k.impr, k.cost];
});
upsertByDate(sh, today, rows);
// 删除超过保留天数的旧记录(记录按日期从早到晚排列)
var last = sh.getLastRow();
if (last > 1 && CONFIG.KEYWORD_HISTORY_DAYS > 0) {
var cutoff = shiftDate(today, -CONFIG.KEYWORD_HISTORY_DAYS);
var dates = sh.getRange(2, 1, last - 1, 1).getValues();
var n = 0; while (n < dates.length && dateStr(dates[n][0]) < cutoff) n++;
if (n > 0) { sh.deleteRows(2, n); log('信息', '关键字历史', '删除了 ' + n + ' 行早于 ' + cutoff + ' 的记录'); }
}
var cells = (sh.getLastRow()) * KH_HEADER.length;
if (cells > 8000000) log('提示', '关键字历史', '表格已接近 1000 万格上限(约 ' + cells + ' 格),建议调小 KEYWORD_HISTORY_DAYS');
}
// 快照:用来和下一次运行对比。同一天重复运行时,对比基准仍然是前一天的记录。
function loadBaseline(ss, today) {
var cur = readSnapshot(ss.getSheetByName('_快照'));
if (cur && cur.date && cur.date < today) return cur;
var prev = readSnapshot(ss.getSheetByName('_快照_上一期'));
return prev || { date: '', map: {} };
}
function saveSnapshot(ss, today, kws) {
var cur = ss.getSheetByName('_快照');
var curDate = cur && cur.getLastRow() > 1 ? dateStr(cur.getRange(2, 1).getValue()) : '';
if (cur && curDate && curDate < today) {
var old = ss.getSheetByName('_快照_上一期');
if (old) ss.deleteSheet(old);
cur.setName('_快照_上一期');
cur = null;
}
var sh = cur || ss.insertSheet('_快照');
sh.clear();
var rows = [['日期', '关键字键', '质量得分']].concat(kws.map(function (k) { return [today, k.key, k.qs || '']; }));
sh.getRange(1, 1, rows.length, 1).setNumberFormat('@');
sh.getRange(1, 2, rows.length, 1).setNumberFormat('@');
sh.getRange(1, 1, rows.length, 3).setValues(rows);
['_快照', '_快照_上一期'].forEach(function (n) { var s = ss.getSheetByName(n); if (s) s.hideSheet(); });
}
function readSnapshot(sh) {
if (!sh || sh.getLastRow() < 2) return null;
var v = sh.getRange(2, 1, sh.getLastRow() - 1, 3).getValues();
var map = {};
v.forEach(function (r) { map[String(r[1])] = { qs: num(r[2]) }; });
return { date: dateStr(v[0][0]), map: map };
}
function writeDashboard(ss, today, s, baseDate) {
var sh = ss.getSheetByName('仪表盘') || ss.insertSheet('仪表盘');
sh.clear();
sh.getCharts().forEach(function (c) { sh.removeChart(c); });
var prev = previousDaily(ss, today);
var kpi = [
['质量得分仪表盘', '', '', ''],
['日期', today, '对比', prev ? prev.date : '(暂无)'],
['', '', '', ''],
['指标', '当前', '上一期', '变化'],
['费用加权质量得分', s.costWQs, prev ? prev.costWQs : '', prev && s.costWQs !== '' && prev.costWQs !== '' ? s.costWQs - prev.costWQs : ''],
['展示加权质量得分', s.imprWQs, prev ? prev.imprWQs : '', prev && s.imprWQs !== '' && prev.imprWQs !== '' ? s.imprWQs - prev.imprWQs : ''],
['平均质量得分(不加权)', s.avgQs, prev ? prev.avgQs : '', prev && s.avgQs !== '' && prev.avgQs !== '' ? s.avgQs - prev.avgQs : ''],
['低分关键字费用占比(QS ≤ ' + CONFIG.LOW_QS + ')', s.lowShare, prev ? prev.lowShare : '', ''],
['有展示的关键字 / 有得分的关键字', s.kw + ' / ' + s.kwQs, '', ''],
['近 ' + CONFIG.DAYS + ' 天费用', s.cost, '', '']
];
sh.getRange(1, 1, kpi.length, 4).setValues(kpi.map(cleanRow));
sh.getRange(1, 1).setFontSize(14).setFontWeight('bold');
sh.getRange(4, 1, 1, 4).setFontWeight('bold').setBackground('#173a52').setFontColor('#ffffff');
sh.getRange(5, 2, 3, 3).setNumberFormat(FMT.qs);
sh.getRange(8, 2, 1, 2).setNumberFormat(FMT.pct);
sh.getRange(10, 2).setNumberFormat(FMT.money);
// 得分分布
var r0 = 12;
var dist = [['质量得分', '关键字数', '展示占比', '费用占比']];
for (var q = 1; q <= 10; q++) dist.push([String(q), s.dist[q].n, div(s.dist[q].impr, s.impr), div(s.dist[q].cost, s.cost)]);
dist.push(['无得分', s.dist['无'].n, div(s.dist['无'].impr, s.impr), div(s.dist['无'].cost, s.cost)]);
sh.getRange(r0, 1, dist.length, 4).setValues(dist);
sh.getRange(r0, 1, 1, 4).setFontWeight('bold').setBackground('#173a52').setFontColor('#ffffff');
sh.getRange(r0 + 1, 3, dist.length - 1, 2).setNumberFormat(FMT.pct);
// 三个组成部分
var c0 = r0 + dist.length + 1;
var comp = [['组成部分', '低于平均(展示占比)', '平均', '高于平均']];
COMPS.forEach(function (c) {
var x = s.comp[c.key];
comp.push([c.name, x.belowShare, x.imprAll ? x.AVERAGE / x.imprAll : '', x.aboveShare]);
});
sh.getRange(c0, 1, comp.length, 4).setValues(comp);
sh.getRange(c0, 1, 1, 4).setFontWeight('bold').setBackground('#173a52').setFontColor('#ffffff');
sh.getRange(c0 + 1, 2, comp.length - 1, 3).setNumberFormat(FMT.pct);
sh.setColumnWidth(1, 280); sh.setColumnWidth(2, 140); sh.setColumnWidth(3, 140); sh.setColumnWidth(4, 140);
// 图表:得分分布(费用占比)、组成部分、每日趋势
sh.insertChart(sh.newChart().asColumnChart()
.addRange(sh.getRange(r0, 1, 11, 1)).addRange(sh.getRange(r0, 4, 11, 1))
.setOption('title', '各质量得分的费用占比').setOption('legend', { position: 'none' })
.setPosition(1, 6, 0, 0).build());
sh.insertChart(sh.newChart().asBarChart().setStacked()
.addRange(sh.getRange(c0, 1, comp.length, 4))
.setOption('title', '三个组成部分(按展示占比)').setOption('colors', ['#c62828', '#9aa3ad', '#2e7d32'])
.setPosition(20, 6, 0, 0).build());
var daily = ss.getSheetByName('每日汇总');
if (daily && daily.getLastRow() > 2) {
sh.insertChart(sh.newChart().asLineChart()
.addRange(daily.getRange(1, 1, daily.getLastRow(), 1)).addRange(daily.getRange(1, 5, daily.getLastRow(), 2))
.setOption('title', '加权质量得分趋势').setOption('vAxis', { minValue: 1, maxValue: 10 })
.setPosition(39, 6, 0, 0).build());
}
}
// 找到今天之前最近的一条每日汇总。dailyOnly = true 时跳过「历史回填」的记录(口径不同,不用于下降提醒)
function previousDaily(ss, today, dailyOnly) {
var sh = ss.getSheetByName('每日汇总');
if (!sh || sh.getLastRow() < 2) return null;
var v = sh.getRange(2, 1, sh.getLastRow() - 1, DAILY_HEADER.length).getValues();
for (var i = v.length - 1; i >= 0; i--) {
var d = dateStr(v[i][0]);
if (dailyOnly && v[i][DAILY_HEADER.length - 1] === '历史回填') continue;
if (d && d < today) return { date: d, avgQs: v[i][3], imprWQs: v[i][4], costWQs: v[i][5], lowShare: v[i][6] };
}
return null;
}
// ======================= 行动清单 =======================
function buildActions(ss, kws, s, ags, baseDate) {
var A = []; // [优先级, 类别, 对象, 发现, 数据, 建议]
var prev = previousDaily(ss, todayOf(ss), true);
if (prev && s.costWQs !== '' && prev.costWQs !== '' && s.costWQs - prev.costWQs <= -0.3) {
A.push(['高', '账户', '全部搜索广告', '费用加权质量得分下降', r2(prev.costWQs) + ' → ' + r2(s.costWQs) + '(对比 ' + prev.date + ')',
'查看「得分变化」表,看下降集中在哪些广告组;同时检查最近是否改过广告、落地页或网站']);
}
var drops = kws.filter(function (k) { return k.change !== '' && k.change <= -CONFIG.DROP_ALERT; })
.sort(function (a, b) { return b.cost - a.cost; });
drops.slice(0, 20).forEach(function (k) {
A.push(['高', '关键字', k.campaign + ' / ' + k.group + ' / ' + k.text, '质量得分下降 ' + Math.abs(k.change) + ' 分',
k.prevQs + ' → ' + k.qs + ',费用 ' + r2(k.cost),
k.weak.length ? adviceFor(k) : '检查这个关键字对应的广告和着陆页最近是否有改动']);
});
if (drops.length > 20) A.push(['高', '关键字', '另外 ' + (drops.length - 20) + ' 个关键字', '质量得分下降', '', '见「得分变化」表']);
var listed = {}; drops.slice(0, 20).forEach(function (k) { listed[k.key] = 1; });
kws.filter(function (k) { return k.qs > 0 && k.qs <= CONFIG.LOW_QS && k.cost > 0 && !listed[k.key]; })
.sort(function (a, b) { return b.priority - a.priority; }).slice(0, 15).forEach(function (k) {
A.push(['高', '关键字', k.campaign + ' / ' + k.group + ' / ' + k.text, '低分关键字花费较多',
'质量得分 ' + k.qs + ',费用 ' + r2(k.cost), adviceFor(k) || '整体提升广告和着陆页']);
});
(ags || []).filter(function (m) { return m.lowShare !== '' && m.lowShare >= 0.5 && m.cost > 0; })
.slice(0, 15).forEach(function (m) {
A.push(['中', '广告组', m.campaign + ' / ' + m.group, '一半以上的费用花在低分关键字上',
'低分费用占比 ' + Math.round(m.lowShare * 100) + '%,最常见短板:' + (m.mainWeak ? compName(m.mainWeak) : '—'),
m.mainWeak ? TIPS[m.mainWeak] : '检查广告组里的关键字主题是否一致']);
});
var worst = COMPS.slice().sort(function (a, b) { return num(s.comp[b.key].belowShare) - num(s.comp[a.key].belowShare); })[0];
if (worst && num(s.comp[worst.key].belowShare) >= 0.25) {
A.push(['中', '账户', '全部搜索广告', '账户最大的短板是「' + worst.name + '」',
Math.round(s.comp[worst.key].belowShare * 100) + '% 的展示来自这一项低于平均的关键字', TIPS[worst.key]]);
}
if (s.cost > 0 && s.noQsCost / s.cost >= 0.2) {
A.push(['低', '账户', '全部搜索广告', '较多费用花在没有质量得分的关键字上', '占费用 ' + Math.round(s.noQsCost / s.cost * 100) + '%',
'通常是新关键字或搜索量太少,先按转化和 ROAS 判断,积累数据后才会有得分']);
}
if (!baseDate) A.push(['低', '说明', '—', '第一次运行', '', '建议设置为每天运行一次,从明天起可以看到得分变化']);
var rank = { '高': 0, '中': 1, '低': 2 };
A.sort(function (a, b) { return rank[a[0]] - rank[b[0]]; });
if (!A.length) A.push(['—', '—', '—', '没有发现需要处理的问题', '', '继续每天记录']);
var header = ['优先级', '类别', '对象', '发现', '数据', '建议'];
var sh = write(ss, '行动清单', header, A, null);
sh.getRange(2, 1, A.length, 1).setBackgrounds(A.map(function (a) {
return [a[0] === '高' ? '#fde2e1' : a[0] === '中' ? '#fff1d6' : '#eef1f5'];
}));
sh.setColumnWidth(3, 300); sh.setColumnWidth(4, 220); sh.setColumnWidth(5, 240); sh.setColumnWidth(6, 440);
sh.getRange(2, 1, A.length, header.length).setWrap(true).setVerticalAlignment('top');
return A;
}
// ======================= 说明、日志、邮件 =======================
function writeGuide(ss, acct, started) {
var rows = [
['质量得分追踪与诊断(' + VERSION + ')', ''],
['账户', acct.getName() + '(' + acct.getCustomerId() + ')'],
['运行时间', Utilities.formatDate(started, acct.getTimeZone(), 'yyyy-MM-dd HH:mm')],
['效果数据时间范围', '近 ' + CONFIG.DAYS + ' 天(截至昨天);质量得分为运行当天的当前值'],
['网络', CONFIG.GOOGLE_SEARCH_ONLY ? '只含 Google 搜索' : '含搜索网络合作伙伴'],
['低分标准', '质量得分 ≤ ' + CONFIG.LOW_QS],
['', ''],
['工作表', '看什么'],
['行动清单', '按优先级列出需要处理的问题,先看这里'],
['仪表盘', '加权质量得分、得分分布、三个组成部分和趋势图'],
['关键字诊断', '每个关键字的得分、三个组成部分、效果和建议,按优先级分数排序'],
['广告组汇总 / 广告系列汇总', '每个广告组和广告系列的加权得分、低分费用占比和最常见的短板'],
['得分变化', '和上一次记录相比,得分上升或下降的关键字'],
['每日汇总', '每天一行的账户汇总,用来看长期趋势'],
['关键字历史', '每个关键字每天的得分(可在设置里关闭),超过保留天数自动删除'],
['运行日志', '每一步是否成功,出错时看这里'],
['', ''],
['费用加权质量得分', '按每个关键字的费用加权平均,最能反映「花出去的钱」处在什么质量水平'],
['优先级分数', '费用 × (目标分数 ' + CONFIG.TARGET_QS + ' − 当前分数),花钱多、得分低的关键字排在前面'],
['注意', '质量得分是诊断工具,不直接参与竞价;判断关键字好坏最终还要看转化和 ROAS'],
['教程', 'https://berightdigital.com/quality-score-tracker-script/']
];
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(8, 1, 1, 2).setFontWeight('bold').setBackground('#173a52').setFontColor('#ffffff');
sh.setColumnWidth(1, 240); sh.setColumnWidth(2, 640);
}
function writeLog(ss) {
var sh = ss.getSheetByName('运行日志') || ss.insertSheet('运行日志');
sh.clear();
var rows = [['时间', '级别', '步骤', '内容']].concat(RUN_LOG);
sh.getRange(1, 1, rows.length, 4).setValues(rows);
sh.getRange(1, 1, 1, 4).setFontWeight('bold');
sh.setColumnWidth(4, 700);
}
function sendEmail(acct, ss, today, s, actions, drops) {
var lines = ['质量得分报告:' + acct.getName() + '(' + today + ')', '',
'费用加权质量得分:' + r2(s.costWQs) + ';展示加权:' + r2(s.imprWQs) + ';低分关键字费用占比:' +
(s.lowShare === '' ? '—' : Math.round(s.lowShare * 100) + '%'),
'质量得分下降 ' + CONFIG.DROP_ALERT + ' 分以上的关键字:' + drops + ' 个', '', '优先处理:'];
actions.filter(function (a) { return a[0] === '高'; }).slice(0, 15).forEach(function (a) {
lines.push('· [' + a[1] + '] ' + a[2] + ':' + a[3] + '(' + a[4] + ')');
});
lines.push('', '完整报告:' + ss.getUrl());
MailApp.sendEmail(CONFIG.EMAIL, '质量得分报告 - ' + acct.getName() + ' - ' + today, lines.join('\n'));
log('信息', '邮件', '已发送到 ' + CONFIG.EMAIL);
}
// ======================= 工具函数 =======================
function openSpreadsheet(acct) {
if (CONFIG.SPREADSHEET_URL) return SpreadsheetApp.openByUrl(CONFIG.SPREADSHEET_URL);
var ss = SpreadsheetApp.create('质量得分追踪 - ' + acct.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;
}
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 range(days) {
var tz = AdsApp.currentAccount().getTimeZone(), DAY = 864e5;
var end = new Date(Date.now() - DAY), start = new Date(Date.now() - Number(days) * DAY);
return ' segments.date BETWEEN "' + Utilities.formatDate(start, tz, 'yyyy-MM-dd') + '" AND "' +
Utilities.formatDate(end, tz, 'yyyy-MM-dd') + '" ';
}
function clean(s) { return String(s || '').replace(/["\\%]/g, ''); }
function nameFilters() {
var inc = clean(CONFIG.CAMPAIGN_NAME_CONTAINS), exc = clean(CONFIG.CAMPAIGN_NAME_EXCLUDES);
return (inc ? ' AND campaign.name LIKE "%' + inc + '%"' : '') + (exc ? ' AND campaign.name NOT LIKE "%' + exc + '%"' : '');
}
function todayOf(ss) {
return Utilities.formatDate(new Date(), AdsApp.currentAccount().getTimeZone(), 'yyyy-MM-dd');
}
function shiftDate(ymd, days) {
var p = ymd.split('-'), d = new Date(Date.UTC(+p[0], +p[1] - 1, +p[2]) + days * 864e5);
return d.toISOString().slice(0, 10);
}
// 表格里的日期可能被读成 Date 对象,统一转成 yyyy-MM-dd 文字
function dateStr(v) {
if (v instanceof Date) return Utilities.formatDate(v, AdsApp.currentAccount().getTimeZone(), 'yyyy-MM-dd');
return String(v || '').slice(0, 10);
}
// 按日期写入:先删除表里同一天的旧记录(同一天重复运行),再追加到末尾
function upsertByDate(sh, date, rows) {
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) return;
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));
}
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 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 === '' ? '—' : Math.round(num(x) * 100) / 100; }
function cn(map, v) { return v === '' || v === undefined ? '' : (map[v] !== undefined ? map[v] : String(v)); }
function values(o) { return Object.keys(o).map(function (k) { return o[k]; }); }
function compName(key) { for (var i = 0; i < COMPS.length; i++) if (COMPS[i].key === key) return COMPS[i].name; return ''; }
function qsColor(q) { q = Number(q); return !(q > 0) ? null : q <= CONFIG.LOW_QS ? '#fde2e1' : q <= 6 ? '#fff1d6' : '#e3f4e8'; }
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 cap(data, name) {
if (data.length > CONFIG.MAX_ROWS) {
log('提示', name, '共 ' + data.length + ' 行,只写入优先级最高的 ' + CONFIG.MAX_ROWS + ' 行');
return data.slice(0, CONFIG.MAX_ROWS);
}
return data;
}
function write(ss, name, header, data, formats) {
var sh = ss.getSheetByName(name) || ss.insertSheet(name);
sh.clear();
var body = data.length ? data.map(cleanRow) : [header.map(function (_, i) { return i === 0 ? '(没有数据)' : ''; })];
if (formats && data.length) {
formats.forEach(function (f, i) { if (f) sh.getRange(2, i + 1, data.length, 1).setNumberFormat(f); });
}
sh.getRange(1, 1, 1, header.length).setValues([header])
.setFontWeight('bold').setBackground('#173a52').setFontColor('#ffffff');
sh.getRange(2, 1, body.length, header.length).setValues(body);
sh.setFrozenRows(1);
return sh;
}
// 以 = + - @ 开头的文字(例如关键字「+running shoes」)会被当成公式,前面加 ' 保持为文字
function cleanRow(row) {
return row.map(function (v) {
if (typeof v === 'string' && /^[=+\-@]/.test(v)) return "'" + v;
return v === undefined || v === null ? '' : v;
});
}
function orderSheets(ss) {
var order = ['使用说明', '行动清单', '仪表盘', '关键字诊断', '广告组汇总', '广告系列汇总', '得分变化',
'每日汇总', '关键字历史', '运行日志'];
order.forEach(function (n, i) {
var sh = ss.getSheetByName(n);
if (sh) { ss.setActiveSheet(sh); ss.moveActiveSheet(i + 1); }
});
// 快照表只给脚本对比用,保持隐藏(激活隐藏的表会让它显示出来,所以不参与排序)
['_快照', '_快照_上一期'].forEach(function (n) { var s = ss.getSheetByName(n); if (s) 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('行动清单');
if (first) ss.setActiveSheet(first);
}
如果运行中遇到问题,把「运行日志」里的错误行发给我们,我们会尽快更新脚本。
毕瑞出海BerightDigital微信扫码关注,视频和文章同步更新