Google Ads 商品分层标签脚本:自动把商品分成英雄、潜力、吸金、僵尸,按利润拆分 PMax(附中文代码)

免费的 Google Ads 商品分层脚本:按最近 90 天的转化、ROAS 或利润(POAS)把购物广告和 PMax 的商品分成英雄、潜力、吸金、僵尸等几层,可选按价格分档,自动生成 Merchant Center 补充数据源,用 custom_label 拆分广告系列。可以替代付费的商品分层工具。
本文目录

把几百上千个商品放进同一个 PMax 广告系列,设一个目标 ROAS,是很多跨境卖家的默认做法。问题是:Google 只按整体 ROAS 优化,结果往往是

  • 少数几个爆款拿走大部分预算(这还算好的);
  • 一批「点击很多、几乎不出单」的商品持续吸走预算,但被爆款的好数据掩盖了;
  • 一半以上的商品几乎没有展示,从来没有被测试过;
  • 低毛利商品的 ROAS 看起来不错,其实卖一单亏一单。

解决方法是按商品表现分层,每一层单独建广告系列或商品组,设不同的预算和目标。ProfitMetrics 的 Shopping Booster 等付费工具就是做这件事的。这个脚本用 Google Ads 自带的数据实现同样的思路,免费。

分成哪几层

分层标签值规则(默认)怎么投
英雄hero转化 ≥ 3 次,ROAS(或 POAS)达到目标单独系列,给足预算,目标设在账户目标附近
潜力sleeper有转化且达标,但展示低于中位数单独系列,降低目标 ROAS 或不设目标,帮它放量
一般normal有数据、不好不坏保持现状
数据不足lowdata点击少于 30 次先积累数据,可以和僵尸商品一起投
吸金villain点击 ≥ 30,ROAS 低于目标的一半,花费超过一次转化的平均成本单独系列设高目标,或直接排除;检查价格和落地页
僵尸zombie统计期内展示少于 50 次先排查是否拒登或缺货,再优化标题图片,单独给曝光

所有门槛都能在设置里改。目标 ROAS 不填时,用账户整体 ROAS 作为标准。

按利润分层(POAS)

ROAS 只看营业额,不看毛利。毛利 20% 的商品 ROAS 400% 是亏钱的,毛利 60% 的商品 ROAS 200% 是赚钱的。

在设置里填上毛利率(可以按品牌、商品类型、商品 ID 分别设置),脚本会改用 POAS = 毛利 ÷ 广告费 判断:POAS ≥ 1 表示扣除广告费后还有利润。

价格分档(可选)

打开 PRICE_LABEL_INDEX 后,脚本会把商品按价格从低到高平均分成 5 档(price-1 最便宜),写到另一个 custom_label。不同价位的商品转化率和客单价差别很大,分开投放更容易设定合理的目标。

参考了哪些工具和脚本

原工具/脚本做什么不足
Shopping Booster(ProfitMetrics,付费)按利润和表现给商品分层,自动同步标签付费;需要接入后台数据
价格分层脚本(Teodor Yordanov)导出商品数据,在表格里按十等分分层需要复制模板表格;分层要手工在表格里做;不能直接回写到 Merchant Center
PMax V3/V4 看板(jack.tm)按商品看 PMax 效果只看数据,不分层,不生成标签

升级版的特点:

  • 包含零展示商品:报告里没有展示的商品不会出现在效果报告里,脚本额外读取全部商品列表,所以僵尸商品不会漏掉。
  • 自动生成补充数据源:「补充Feed」表就是 Merchant Center 能直接读取的格式,关联一次以后每天自动同步。
  • 支持毛利和 POAS,按品牌/类型/ID 设置不同毛利率。
  • 每日记录:每天各层商品数量和吸金商品的费用占比,调整后能看到变化。

脚本不会修改广告账户。 它只更新表格,标签由 Merchant Center 从表格读取。

安装步骤(10 分钟)

  1. 登录 Google Ads,点击左侧「工具」→「批量操作」→「脚本」。
  2. 点击蓝色「+」按钮,选择「新建脚本」,起个容易认的名字。
  3. 删掉编辑器里默认的代码,把本文末尾的完整代码全部粘贴进去(也可以从 GitHub 下载)。
  4. 修改代码开头 CONFIG 里的设置,至少填好 EMAIL(接收提醒的邮箱)。
  5. 点击「授权」,用有这个账户权限的 Google 账号登录并允许访问。
  6. 点击「预览」。第一次运行会自动新建一个 Google 表格,在下方「日志」里找到以「★ 新建的表格」开头的一行,复制网址填到 SPREADSHEET_URL 的两个引号中间,保存。
  7. 回到脚本列表,设置运行频率(见下方建议)。

预览模式下脚本也会正常写入表格,但不会修改账户里的任何设置。

建议运行频率:每天一次。标签变化后,Merchant Center 下次抓取补充数据源时会同步。

设置说明

设置默认值说明
SPREADSHEET_URL空报告表格网址,第一次运行后填入
EMAIL空每次运行后发摘要
DAYS90统计天数,建议 60–90 天
TARGET_ROAS0目标 ROAS(3 = 300%);0 = 用账户整体 ROAS
HERO_MIN_CONV3英雄商品至少几次转化
MIN_CLICKS30少于多少点击算数据不足
VILLAIN_ROAS_RATIO0.5ROAS 低于目标的这个比例算吸金
ZOMBIE_IMPRESSIONS50展示少于多少算僵尸
LABEL_INDEX0分层写入哪个 custom_label(0–4)
PRICE_LABEL_INDEX-1价格分档写入哪个 custom_label;-1 = 不做
LABEL_NAMES英文标签每一层在 Merchant Center 里显示的标签值
DEFAULT_MARGIN0默认毛利率(0–1);0 = 不按利润计算
MARGINS空按品牌、类型、商品 ID 设置不同毛利率
ID_CASEas-is商品 ID 大小写,见下文

LABEL_INDEX 一定要选一个 Feed 里没在用的 custom_label,否则补充数据源会覆盖你原来的标签。

第一步:关联补充数据源

  1. 运行脚本,确认「补充Feed」表有数据(第一列 id,第二列 custom_label_0)。
  2. 打开 Merchant Center → 设置 → 数据源 → 添加补充数据源 → 选择 Google 表格 → 选择「使用现有的 Google 表格」,粘贴这个表格的网址。
  3. 选择要补充的主数据源,设置每天自动抓取。
  4. 等 Merchant Center 处理完成后,在商品详情里能看到 custom_label_0 有了值。

「补充Feed」表必须保持在表格的第一个位置,表头不要改。

关于商品 ID 的大小写

Google Ads 报告里的商品 ID 都是小写的,而补充数据源要求 ID 和主 Feed 完全一致。

  • 你的商品 ID 全是小写或数字(大部分 Shopify 店铺是 shopify_us_123456_789):保持 as-is。
  • 全是大写(例如 SKU-1001):填 upper。
  • 大小写混合:建议在主 Feed 里把 ID 统一成小写,或者把补充数据源的 ID 列改成用公式从主 Feed 查找。

第二步:按标签拆分广告系列

标签同步后,常见的拆分方式:

  • PMax:每个分层建一个广告系列,在素材资源组的「列表组」里按 custom_label_0 选择商品。例如「PMax-英雄」只包含 hero,「PMax-潜力」包含 sleeper 和 lowdata。
  • 标准购物广告:在一个系列里按 custom_label_0 细分商品组,分别设出价。
  • 吸金商品:可以单独放一个高目标 ROAS 的系列,或者在其他系列里排除 villain。
  • 英雄商品要防止被拖累:不要和吸金商品放在同一个预算里。

拆分后先观察 2–3 周,再根据「每日记录」里的变化调整目标。

常见问题

商品会在不同层之间来回跳吗?

会,这正是动态分层的意义。为了减少跳动,统计期默认用 90 天。如果跳得太频繁,可以把 DAYS 改成 120,或者提高 HERO_MIN_CONV。

按商品拆分后,PMax 会不会因为数据太少学习不好?

会有影响。转化很少的账户不建议拆太细,可以只拆两层:英雄 + 其他。一般建议每个 PMax 系列每月至少有 30 次转化。

多个国家的 Feed 怎么办?

同一个商品 ID 在多个国家/Feed 标签下会合并计算一次分层。如果不同国家的表现差别很大,可以给每个国家单独运行一份脚本(用 CAMPAIGN_NAME_CONTAINS 只统计该国家的广告系列),写入不同的表格和不同的 custom_label。

完整代码

代码也放在 GitHub 上,可以直接下载:product-tiers.js。复制时点击代码框右上角的按钮,或者全选后复制。

/**
 * 商品表现分层标签 v1.0(毕瑞出海中文版)
 * ------------------------------------------------------------
 * 作用:按最近 90 天的效果把购物广告 / PMax 里的每个商品分成几层:
 *   英雄(hero)    转化多、ROAS 达标         → 给足预算
 *   潜力(sleeper) ROAS 达标但展示太少       → 单独拉出来放量
 *   吸金(villain) 花钱多、ROAS 远低于目标   → 单独收紧或排除
 *   一般(normal)  其他有数据的商品
 *   数据不足(lowdata)点击太少,还看不出好坏
 *   僵尸(zombie)  几乎没有展示的商品         → 单独建系列给曝光,或优化标题图片
 * 可选按价格分档(price-1 ~ price-5),可选按毛利计算利润和 POAS。
 * 结果写成 Merchant Center「补充数据源」格式(id + custom_label),在 Merchant Center 里关联这张表,
 * 商品会自动带上标签,然后按标签拆分广告系列或商品组。
 *
 * 思路参考 ProfitMetrics Shopping Booster(付费工具)、Teodor Yordanov 的价格分层脚本、
 * 以及 Mike Rhodes 等人的「英雄/僵尸商品」做法,代码为重新编写。
 *
 * 教程:https://berightdigital.com/google-ads-product-tiers-script/
 * ------------------------------------------------------------
 */

// ======================= 设置(只需要改这里) =======================
var CONFIG = {
  // Google 表格网址。留空 = 第一次运行时自动新建,网址打印在「日志」里,复制回这里。
  SPREADSHEET_URL: '',
  // 每次运行后发一封摘要邮件(留空 = 不发)
  EMAIL: '',

  // 只统计名称包含 / 不包含这段文字的广告系列(留空 = 不限)
  CAMPAIGN_NAME_CONTAINS: '',
  CAMPAIGN_NAME_EXCLUDES: '',

  // 统计最近多少天(建议 60–90 天,太短数据不稳定)
  DAYS: 90,
  // 目标 ROAS(例如 3 = 300%)。0 = 用账户整体 ROAS
  TARGET_ROAS: 0,

  // 分层门槛
  HERO_MIN_CONV: 3,       // 英雄:至少这么多次转化,且 ROAS ≥ 目标
  MIN_CLICKS: 30,         // 少于这么多点击 → 数据不足
  VILLAIN_ROAS_RATIO: 0.5, // 吸金:ROAS 低于目标的这个比例(且花费 ≥ 一次转化的平均成本)
  ZOMBIE_IMPRESSIONS: 50, // 统计期内展示少于这么多 → 僵尸

  // 标签写到哪个 custom_label(0–4)。请选一个 Feed 里没在用的。
  LABEL_INDEX: 0,
  // 价格分档写到哪个 custom_label(0–4),-1 = 不做价格分档
  PRICE_LABEL_INDEX: -1,
  // 标签值(Merchant Center 里显示的文字,建议保持英文)
  LABEL_NAMES: { hero: 'hero', sleeper: 'sleeper', villain: 'villain', normal: 'normal', lowdata: 'lowdata', zombie: 'zombie' },

  // 毛利率(0–1)。填了就会计算利润和 POAS,并用 POAS 代替 ROAS 判断英雄和吸金。0 = 不用
  DEFAULT_MARGIN: 0,
  // 按品牌 / 商品类型 / 商品 ID 设置不同毛利率,第一个匹配的生效。field 可选 'brand' 'type' 'id'
  MARGINS: [
    // { field: 'brand', contains: 'yourbrand', margin: 0.45 },
  ],

  // 商品 ID 的大小写:Google Ads 报告里的 ID 是小写的。
  // Merchant Center 里的 ID 有大写字母时填 'upper',大小写混合时请看教程里的说明
  ID_CASE: 'as-is'
};
// ======================= 设置结束,下面的代码一般不需要改 =======================

var VERSION = 'v1.0';
var TIER_CN = { hero: '英雄', sleeper: '潜力', villain: '吸金', normal: '一般', lowdata: '数据不足', zombie: '僵尸' };
var TIER_ORDER = ['hero', 'sleeper', 'normal', 'lowdata', 'villain', 'zombie'];


function main() {
  var ss = openSpreadsheet('商品分层标签');
  log('信息', '开始', '版本 ' + VERSION + ',统计最近 ' + CONFIG.DAYS + ' 天');

  var products = safe('读取商品', fetchProducts) || {};
  var list = values(products);
  if (!list.length) { log('错误', '商品', '没有读取到商品,请确认账户在投放购物广告或 PMax'); writeLog(ss); return; }
  log('信息', '商品', list.length + ' 个商品');

  var ref = reference(list);
  log('信息', '目标', '目标 ROAS ' + r2(ref.roas) + (CONFIG.TARGET_ROAS ? '(设置值)' : '(账户整体)') + ',平均每次转化费用 ' + r2(ref.cpa) + (ref.useProfit ? ',按毛利计算 POAS' : ''));
  list.forEach(function (p) { classify(p, ref); });
  if (CONFIG.PRICE_LABEL_INDEX >= 0) priceTiers(list);

  var sum = summarize(list);
  safe('补充 Feed', function () { writeFeed(ss, list); });
  safe('商品分层', function () { writeProducts(ss, list, ref); });
  safe('分层汇总', function () { writeSummary(ss, sum, ref); });
  safe('每日记录', function () { history(ss, sum); });
  var A = buildActions(sum, ref);
  safe('行动清单', function () { writeActions(ss, A, '商品分布正常'); });

  if (CONFIG.EMAIL) safe('邮件', function () {
    var rows = [['分层', '商品数', '费用占比', '转化价值占比', 'ROAS']].concat(TIER_ORDER.map(function (t) {
      var x = sum[t]; return [TIER_CN[t], x.n, pct(div(x.cost, sum.all.cost)), pct(div(x.value, sum.all.value)), r2(x.roas)];
    }));
    mail('[商品分层] ' + AdsApp.currentAccount().getName() + ':吸金商品花掉 ' + pct(div(sum.villain.cost, sum.all.cost)) + ' 的预算',
      '最近 ' + CONFIG.DAYS + ' 天,' + list.length + ' 个商品的分层结果如下。补充 Feed 已更新,Merchant Center 会在下次抓取时同步标签。', rows, ss);
  });

  safe('使用说明', function () {
    writeGuide(ss, '商品表现分层标签', VERSION, [
      ['统计范围', '最近 ' + CONFIG.DAYS + ' 天的购物广告和 PMax 商品数据'],
      ['目标', '目标 ROAS ' + r2(ref.roas) + (ref.useProfit ? ';已设置毛利率,英雄/吸金按 POAS(利润 ÷ 广告费)判断' : '')],
      ['标签位置', '分层写入 custom_label_' + CONFIG.LABEL_INDEX + (CONFIG.PRICE_LABEL_INDEX >= 0 ? ',价格分档写入 custom_label_' + CONFIG.PRICE_LABEL_INDEX : '')],
      ['', ''],
      ['补充Feed', '这张表是给 Merchant Center 读取的,请保持在第一个位置,不要改表头。在 Merchant Center → 数据源 → 添加补充数据源 → Google 表格,选择这个表格'],
      ['行动清单', '吸金商品花了多少钱、潜力商品有多少,以及怎么按标签调整广告系列'],
      ['商品分层', '每个商品的效果、分层和原因'],
      ['分层汇总', '每一层的商品数、费用占比、价值占比和 ROAS'],
      ['每日记录', '每天的分层数量,用来观察调整后的变化'],
      ['分层规则', '英雄:转化 ≥ ' + CONFIG.HERO_MIN_CONV + ' 且达到目标;潜力:达到目标但展示低于中位数;吸金:点击 ≥ ' + CONFIG.MIN_CLICKS + ' 且效果低于目标的 ' + CONFIG.VILLAIN_ROAS_RATIO * 100 + '%;僵尸:展示 < ' + CONFIG.ZOMBIE_IMPRESSIONS + ';数据不足:点击 < ' + CONFIG.MIN_CLICKS]
    ]);
  });
  writeLog(ss);
  orderSheets(ss, ['补充Feed', '使用说明', '行动清单', '分层汇总', '商品分层', '每日记录', '运行日志']);
  // 补充 Feed 要在第一个位置,但打开表格时先显示使用说明
  var g1 = ss.getSheetByName('使用说明'); if (g1) ss.setActiveSheet(g1);
}


// ======================= 读取数据 =======================

function fetchProducts() {
  var map = {};
  // 1. 所有商品(包括没有展示的),用来找僵尸商品
  var all = tryQuery('全部商品', 'SELECT shopping_product.item_id, shopping_product.status, shopping_product.title, shopping_product.brand, ' +
    'shopping_product.price_micros, shopping_product.product_type_level1, shopping_product.availability FROM shopping_product');
  if (!all) all = tryQuery('全部商品', 'SELECT shopping_product.item_id, shopping_product.status FROM shopping_product') || [];
  all.forEach(function (r) {
    var p = r.shoppingProduct || {}, id = String(p.itemId || '').toLowerCase();
    if (!id) return;
    var x = map[id] || (map[id] = zero({ id: id, title: '', brand: '', type: '', price: '', status: '', avail: '' }));
    x.title = x.title || String(p.title || ''); x.brand = x.brand || String(p.brand || ''); x.type = x.type || String(p.productTypeLevel1 || '');
    if (p.priceMicros && x.price === '') x.price = num(p.priceMicros) / 1e6;
    if (p.status === 'ELIGIBLE' || !x.status) x.status = String(p.status || '');
    x.avail = x.avail || String(p.availability || '');
  });
  // 2. 效果数据
  var perf = runQuery('SELECT segments.product_item_id, segments.product_title, segments.product_brand, segments.product_type_l1, ' +
    'metrics.impressions, metrics.clicks, metrics.cost_micros, metrics.conversions, metrics.conversions_value ' +
    'FROM shopping_performance_view WHERE ' + range(CONFIG.DAYS) + nameFilter());
  perf.forEach(function (r) {
    var id = String(g(r, 'segments.productItemId')).toLowerCase();
    if (!id) return;
    var x = map[id] || (map[id] = zero({ id: id, title: '', brand: '', type: '', price: '', status: '', avail: '' }));
    x.title = x.title || String(g(r, 'segments.productTitle')); x.brand = x.brand || String(g(r, 'segments.productBrand'));
    x.type = x.type || String(g(r, 'segments.productTypeL1'));
    addMetrics(x, metricsOf(r));
  });
  return map;
}

function reference(list) {
  var t = zero({}); list.forEach(function (p) { addMetrics(t, p); });
  var useProfit = CONFIG.DEFAULT_MARGIN > 0 || CONFIG.MARGINS.length > 0;
  var imprs = list.filter(function (p) { return p.impr > 0; }).map(function (p) { return p.impr; }).sort(function (a, b) { return a - b; });
  return { roas: CONFIG.TARGET_ROAS || div(t.value, t.cost) || 0, cpa: div(t.cost, t.conv) || 0, useProfit: useProfit, total: t,
           medianImpr: imprs.length ? imprs[Math.floor(imprs.length / 2)] : 0 };
}


// ======================= 分层 =======================

function marginOf(p) {
  for (var i = 0; i < CONFIG.MARGINS.length; i++) {
    var m = CONFIG.MARGINS[i], v = String(p[m.field] || '').toLowerCase();
    if (m.contains && v.indexOf(String(m.contains).toLowerCase()) >= 0) return num(m.margin);
  }
  return num(CONFIG.DEFAULT_MARGIN);
}

function classify(p, ref) {
  derived(p);
  p.margin = ref.useProfit ? marginOf(p) : '';
  p.profit = ref.useProfit ? p.value * p.margin - p.cost : '';
  p.poas = ref.useProfit && p.cost ? (p.value * p.margin) / p.cost : '';
  // 用 POAS 时,「达标」= 利润为正(POAS ≥ 1);否则 ROAS ≥ 目标
  var score = ref.useProfit ? p.poas : p.roas, target = ref.useProfit ? 1 : ref.roas;
  var good = score !== '' && target > 0 && score >= target;
  if (p.impr < CONFIG.ZOMBIE_IMPRESSIONS) { p.tier = 'zombie'; p.why = '展示只有 ' + p.impr + ' 次'; }
  else if (p.conv >= CONFIG.HERO_MIN_CONV && good) { p.tier = 'hero'; p.why = '转化 ' + r2(p.conv) + ',' + (ref.useProfit ? 'POAS ' + r2(p.poas) : 'ROAS ' + r2(p.roas)); }
  else if (p.conv >= 1 && good && p.impr < ref.medianImpr) { p.tier = 'sleeper'; p.why = '效果达标,但展示 ' + p.impr + ' 低于中位数 ' + ref.medianImpr; }
  else if (p.clicks >= CONFIG.MIN_CLICKS && target > 0 && (score === '' || score < target * CONFIG.VILLAIN_ROAS_RATIO) && p.cost >= (ref.cpa || 0)) {
    p.tier = 'villain'; p.why = '花费 ' + r2(p.cost) + ',' + (p.conv ? (ref.useProfit ? 'POAS ' + r2(p.poas) : 'ROAS ' + r2(p.roas)) : '没有转化');
  }
  else if (p.clicks < CONFIG.MIN_CLICKS && p.conv < 1) { p.tier = 'lowdata'; p.why = '点击 ' + p.clicks + ',数据还不够'; }
  else { p.tier = 'normal'; p.why = ''; }
  if (p.status === 'NOT_ELIGIBLE') p.why += (p.why ? ';' : '') + '当前不能投放';
  if (p.avail === 'OUT_OF_STOCK') p.why += (p.why ? ';' : '') + '缺货';
}

function priceTiers(list) {
  var priced = list.filter(function (p) { return p.price !== ''; }).sort(function (a, b) { return a.price - b.price; });
  var n = priced.length;
  priced.forEach(function (p, i) { p.priceTier = 'price-' + (Math.floor(i / Math.max(1, n) * 5) + 1); });
  list.forEach(function (p) { if (!p.priceTier) p.priceTier = ''; });
  if (n) log('信息', '价格分档', '按价格把 ' + n + ' 个商品平均分成 5 档(price-1 最便宜)');
}

function summarize(list) {
  var s = { all: zero({ n: 0 }) };
  TIER_ORDER.forEach(function (t) { s[t] = zero({ n: 0 }); });
  list.forEach(function (p) { s[p.tier].n++; addMetrics(s[p.tier], p); s.all.n++; addMetrics(s.all, p); });
  TIER_ORDER.concat(['all']).forEach(function (t) { derived(s[t]); });
  return s;
}


// ======================= 输出 =======================

function outId(id) { return CONFIG.ID_CASE === 'upper' ? id.toUpperCase() : id; }

function writeFeed(ss, list) {
  var head = ['id', 'custom_label_' + CONFIG.LABEL_INDEX];
  if (CONFIG.PRICE_LABEL_INDEX >= 0) head.push('custom_label_' + CONFIG.PRICE_LABEL_INDEX);
  var data = list.map(function (p) {
    var row = [outId(p.id), CONFIG.LABEL_NAMES[p.tier] || p.tier];
    if (CONFIG.PRICE_LABEL_INDEX >= 0) row.push(p.priceTier);
    return row;
  });
  var sh = ss.getSheetByName('补充Feed') || ss.insertSheet('补充Feed');
  sh.clear();
  sh.getRange(1, 1, data.length + 1, 1).setNumberFormat('@');
  sh.getRange(1, 1, 1, head.length).setValues([head]);
  if (data.length) sh.getRange(2, 1, data.length, head.length).setValues(data);
}

function writeProducts(ss, list, ref) {
  var rank = {}; TIER_ORDER.forEach(function (t, i) { rank[t] = i; });
  list.sort(function (a, b) { return rank[a.tier] - rank[b.tier] || b.cost - a.cost; });
  var head = ['商品 ID', '标题', '品牌', '商品类型', '价格', '分层', '标签值', '原因'].concat(METRIC_HEAD)
    .concat(ref.useProfit ? ['毛利率', '利润', 'POAS'] : []).concat(CONFIG.PRICE_LABEL_INDEX >= 0 ? ['价格档'] : []).concat(['状态']);
  var data = list.map(function (p) {
    return [p.id, p.title, p.brand, p.type, p.price, TIER_CN[p.tier], CONFIG.LABEL_NAMES[p.tier], p.why].concat(metricCells(p))
      .concat(ref.useProfit ? [p.margin, p.profit, p.poas] : []).concat(CONFIG.PRICE_LABEL_INDEX >= 0 ? [p.priceTier] : []).concat([p.status]);
  });
  write(ss, '商品分层', head, data, ['@', '', '', '', FMT.money, '', '', ''].concat(METRIC_FMT).concat(ref.useProfit ? [FMT.pct, FMT.money, FMT.ratio] : []), {
    max: 40000, colors: { col: 5, fn: function (v) { return v === '英雄' ? '#e3f4e8' : v === '潜力' ? '#e6f0fb' : v === '吸金' ? '#fde2e1' : v === '僵尸' ? '#eef1f5' : null; } } });
}

function writeSummary(ss, s, ref) {
  var head = ['分层', '标签值', '商品数', '商品占比', '费用', '费用占比', '转化', '转化价值', '价值占比', 'ROAS', '每次转化费用', '建议'];
  var tip = {
    hero: '单独建广告系列或商品组,给足预算,目标 ROAS 可以设在账户目标附近',
    sleeper: '单独建广告系列,目标 ROAS 设低一些(或用「尽可能提高转化价值」不设目标)帮它们拿到更多展示',
    normal: '保持现状,定期复查',
    lowdata: '新品或冷门商品,先积累数据;可以和僵尸商品放在同一个系列',
    villain: '单独建广告系列并设较高的目标 ROAS,或者直接排除;同时检查价格竞争力和落地页',
    zombie: '检查标题、图片、价格是否有问题;单独建低目标或无目标的广告系列给曝光'
  };
  var data = TIER_ORDER.map(function (t) {
    var x = s[t];
    return [TIER_CN[t], CONFIG.LABEL_NAMES[t], x.n, div(x.n, s.all.n), x.cost, div(x.cost, s.all.cost), x.conv, x.value, div(x.value, s.all.value), x.roas, x.cpa, tip[t]];
  }).concat([['合计', '', s.all.n, 1, s.all.cost, 1, s.all.conv, s.all.value, 1, s.all.roas, s.all.cpa, '']]);
  write(ss, '分层汇总', head, data, ['', '', FMT.int, FMT.pct, FMT.money, FMT.pct, FMT.dec, FMT.money, FMT.pct, FMT.ratio, FMT.money, ''],
    { title: '目标 ' + (ref.useProfit ? 'POAS 1(盈亏平衡)' : 'ROAS ' + r2(ref.roas)) + ',最近 ' + CONFIG.DAYS + ' 天', widths: [80, 80, 70, 70, 90, 70, 70, 90, 70, 60, 90, 520], wrap: true });
}

var HIST_HEAD = ['日期'].concat(TIER_ORDER.map(function (t) { return TIER_CN[t]; })).concat(['吸金费用占比']);
function history(ss, s) {
  var sh = ensureSheet(ss, '每日记录', HIST_HEAD);
  upsertDay(sh, today(), [[today()].concat(TIER_ORDER.map(function (t) { return s[t].n; })).concat([div(s.villain.cost, s.all.cost)])], 400);
}

function buildActions(s, ref) {
  var A = [], all = s.all;
  var vc = div(s.villain.cost, all.cost);
  if (s.villain.n) A.push([vc >= 0.15 ? '高' : '中', '吸金商品', s.villain.n + ' 个商品', '花掉 ' + pct(vc) + ' 的预算,只带来 ' + pct(div(s.villain.value, all.value)) + ' 的转化价值',
    '费用 ' + r2(s.villain.cost) + ',ROAS ' + r2(s.villain.roas), '按标签「' + CONFIG.LABEL_NAMES.villain + '」单独建系列并提高目标 ROAS,或在现有系列里排除']);
  if (s.hero.n) A.push(['中', '英雄商品', s.hero.n + ' 个商品', '带来 ' + pct(div(s.hero.value, all.value)) + ' 的转化价值', 'ROAS ' + r2(s.hero.roas),
    '按标签「' + CONFIG.LABEL_NAMES.hero + '」单独建系列,保证预算充足,不要和吸金商品抢预算']);
  if (s.sleeper.n) A.push(['中', '潜力商品', s.sleeper.n + ' 个商品', '效果达标但展示太少', 'ROAS ' + r2(s.sleeper.roas) + ',展示 ' + s.sleeper.impr,
    '按标签「' + CONFIG.LABEL_NAMES.sleeper + '」单独建系列,降低目标 ROAS 帮它们放量']);
  if (s.zombie.n / Math.max(1, all.n) >= 0.2) A.push(['中', '僵尸商品', s.zombie.n + ' 个商品', '占全部商品的 ' + pct(s.zombie.n / all.n) + ',几乎没有展示', '',
    '先排查是否不能投放或缺货(见「商品分层」的状态列),再优化标题和图片,或单独建系列给曝光']);
  A.push(['低', '设置', 'Merchant Center', '把「补充Feed」表关联为补充数据源', 'custom_label_' + CONFIG.LABEL_INDEX,
    'Merchant Center → 设置 → 数据源 → 添加补充数据源 → Google 表格 → 选择这个表格,设置每天自动抓取。标签同步后,在广告系列里按 custom_label_' + CONFIG.LABEL_INDEX + ' 拆分商品组或列表组']);
  return A;
}


// ======================= 通用工具函数(毕瑞出海脚本库共用) =======================

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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); };
  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);
}

搭配使用的脚本

本文目录

继续阅读

按主题查找下一篇指南。

进入知识库 →