PMax 脚本中文教程:一键导出渠道、商品、素材和搜索字词(附升级版脚本)

一个免费的 Google Ads 脚本,把效果最大化广告(PMax)按渠道、商品、素材资源组、素材和搜索字词拆开,自动计算 ROAS 并生成行动清单。本文附完整中文版脚本、安装步骤和每张表的解读方法。
本文目录

说明:本文部分工具链接是联盟链接,通过链接购买我们可能获得佣金,你的价格不变。文中先介绍免费做法,付费工具只在确实能省时间时推荐。

效果最大化广告(Performance Max,下面简称 PMax)最让人头疼的地方,是它像一个黑盒:一个广告系列同时在搜索、购物、YouTube、展示广告网络、Gmail、Discover、地图上投放,后台给你的往往只是一个总数。钱到底花在哪个渠道?哪些商品在亏钱?哪些素材在拖后腿?很难一眼看清楚。

澳洲的 Google Ads 专家 Mike Rhodes 写过一个很有名的免费 PMax 脚本,把 PMax 的数据导出到 Google 表格里分析。我们在它的思路基础上,重新编写了一个中文升级版,主要做了这些改进:

  • 不需要先复制模板表格,工作表自动创建;
  • 用 Google Ads 新开放的 PMax 渠道报告,直接看到每个渠道的花费和 ROAS;
  • 新增素材级效果数据、搜索字词报告;
  • 按你自己的盈亏平衡 ROAS 自动判断每个广告系列、渠道、商品是否在赚钱;
  • 自动生成一张「行动清单」,按优先级告诉你先改哪里;
  • 可以每周自动运行,并把摘要发到你的邮箱。

这篇文章讲清楚:脚本能做什么、怎么安装、每张表怎么看,以及常见问题。完整代码在文章后半部分,可以直接复制。

这个脚本会不会改动我的广告

不会。 这个脚本只读取 Google Ads 的报告数据,然后写入 Google 表格,不会修改任何广告系列、出价、预算或素材。所有调整都需要你看完报告后自己在后台操作。

准备工作:算出你的盈亏平衡 ROAS

脚本会用两个数字判断广告是否赚钱:

  • 盈亏平衡 ROAS:低于它,广告在亏钱。计算方法是 1 ÷ 毛利率(扣除商品成本、运费、支付手续费后的毛利率)。例如毛利率 40%,盈亏平衡 ROAS 是 2.5。
  • 目标 ROAS:你希望达到的回报,要高于盈亏平衡 ROAS,留出利润空间。

不会算的话,可以用我们的 ROAS 计算器,填入售价和成本就能得到这两个数字。原理见 ROAS 是什么?盈亏平衡 ROAS 和目标 ROAS 怎么算。

安装步骤

  1. 登录 Google Ads 账户,点击左侧的「工具」,找到「批量操作」→「脚本」。
  2. 点击蓝色的「+」按钮,选择「新脚本」。
  3. 删除编辑框里的默认代码,把文章下方的完整脚本粘贴进去。
  4. 修改脚本最上方「设置」部分的几个数字(见下一节),至少要改 BREAKEVEN_ROAS 和 TARGET_ROAS。
  5. 给脚本起个名字,例如「PMax 体检」,点击「授权」,按提示允许脚本访问你的 Google Ads 数据和 Google 表格。
  6. 点击「预览」或「运行」。预览不会改动广告账户,但会真正创建和写入表格,所以第一次直接点预览就可以。
  7. 运行结束后,点开「日志」,找到「★ 新建的表格」这一行,复制里面的表格网址。
  8. 把这个网址粘贴到脚本的 SPREADSHEET_URL 里,保存。以后每次运行都会更新同一份表格,不会再新建。
  9. 在脚本列表的「频率」一栏设置自动运行,建议每周一次(例如每周一早上)。

设置项说明

设置默认值说明
SPREADSHEET_URL空表格网址。第一次留空,运行后填入日志里的网址
CAMPAIGN_NAME_CONTAINS空只分析名称包含这段文字的 PMax 广告系列,例如「US」。留空表示全部
DAYS30大部分报告的时间范围(天,截至昨天)
LONG_DAYS180商品长期表现的时间范围
ZOMBIE_DAYS365判断僵尸商品的时间范围
BREAKEVEN_ROAS2.5你的盈亏平衡 ROAS
TARGET_ROAS3.5你的目标 ROAS
WASTE_COST50花费超过这个金额仍然没有转化,就标记为「花钱无转化」。建议设为你能接受的单次转化费用,单位是账户币种
ZOMBIE_MIN_IMPRESSIONS100僵尸商品至少要有多少展示才列出
MAX_ROWS5000每个表最多写入多少行,商品很多的账户可以适当调低,避免超时
EMAIL空填入邮箱后,每次运行完会收到一封摘要邮件。多个邮箱用英文逗号分隔

每张表怎么看

运行完成后,表格里会有 12 个工作表。建议按下面的顺序看。

1. 行动清单:先看这里

脚本根据所有报告自动生成的问题清单,按优先级排序:

  • 高(红色):正在亏钱或者有素材无法投放,优先处理;
  • 中(黄色):有优化空间,例如广告效力一般、某个渠道花费占比高但亏损、有僵尸商品;
  • 低(灰色):可以在日常维护时处理,例如评级较差的素材。

每一条都写了「发现」「数据」和「建议」。处理完一条,下次运行时它就会消失或者变化。

2. 广告系列概览

每个 PMax 广告系列近 30 天的费用、展示、点击、转化、转化价值、ROAS 和单次转化费用,最后一列「判断」用颜色标出:

判断含义
达标(绿色)ROAS ≥ 目标 ROAS
保本到目标之间(黄色)盈亏平衡 ROAS ≤ ROAS < 目标 ROAS
亏损(红色)ROAS < 盈亏平衡 ROAS
花钱无转化(红色)费用超过设置的金额,转化为 0
数据不足费用还不多、也没有转化,暂时不判断

3. 渠道拆分

这是升级版最有价值的一张表。它把每个 PMax 广告系列的花费,拆到搜索、YouTube、展示广告网络、Gmail、Discover、地图等渠道,并显示每个渠道的 ROAS。最上面几行是全部 PMax 的合计。

渠道名称后面带「(商品广告)」的,表示这部分流量使用了商品数据,也就是购物类广告;不带的,是用文字、图片、视频素材展示的广告。

怎么用:

  • YouTube 或展示广告网络花费占比高、ROAS 很低:检查视频和图片素材。没有上传视频时,Google 会用你的图片自动生成视频,质量往往不理想,建议上传自己拍的横版和竖版视频。
  • 搜索(非商品广告)花费多但不转化:去「搜索字词」表找出不相关的搜索。
  • 大部分转化来自搜索(商品广告):说明 PMax 主要在做购物广告,其他渠道更多是辅助。

注意:渠道数据从 2025 年 6 月才开始提供。如果这张表只显示「跨网络(未拆分)」,说明你的账户暂时拿不到渠道明细,其他报告不受影响。

4. 每日趋势

每天的费用、转化价值和 ROAS。用来观察:

  • 修改预算、目标 ROAS 或素材之后,效果有没有变化;
  • 是否还在学习期(前一两周波动通常较大);
  • 某几天是否出现异常的花费高峰。

最近几天的转化通常还没统计完整,因为转化是按点击日期统计的,用户可能几天后才下单。看趋势时不要只看最近两三天。

5. 素材资源组

每个素材资源组的状态、广告效力(极佳 / 良好 / 一般 / 差)和效果。广告效力「差」或「一般」的素材资源组,通常是素材数量或类型不够:标题、长标题、说明、横版图片、方形图片、纵向图片、徽标、视频,尽量都补齐,并且标题之间不要重复。

6. 素材明细

每一个标题、说明、图片、视频的:

  • 效果评级:最佳 / 良好 / 较差 / 学习中,和同类型素材比较的结果;
  • 投放状态:符合条件 / 受限 / 不符合条件,受限的素材通常是没有通过政策审核;
  • 效果数据:费用、展示、点击、转化、ROAS(如果你的账户支持素材级数据)。

怎么用:先处理「受限」和「不符合条件」的素材,它们可能根本没有投放;再替换评级「较差」的素材,但每种类型保留足够的数量(例如至少 3–4 个标题),不要一次全部删掉。

7. 商品_近30天 和 商品_近180天

每个商品的费用、展示、点击、点击率、转化、转化价值、ROAS 和分类。两张表的区别是时间范围:30 天看最近的表现,180 天看长期表现,避免因为短期波动误判。

分类怎么处理
达标表现好的商品可以单独建素材资源组,配专属素材,争取更多曝光
保本到目标之间观察,优化商品标题、图片和价格
亏损检查毛利和价格竞争力,可以按毛利给商品打自定义标签,分组设置不同的目标
花钱无转化检查价格、库存、落地页和评价;确认不是新品后,考虑移到单独分组或排除
数据不足费用还少,先不处理

表里保留了自定义标签 0–4,可以在表格里按标签筛选,看看某一类商品(例如高毛利、新品、清仓)的整体表现。商品标题的优化方法可以参考我们的 购物广告 Feed 标题优化工具。

8. 僵尸商品

在过去一年里有展示、但一次点击都没有的商品。原因通常是:

  • 商品标题前几个词没有用户会搜的关键词;
  • 主图在一堆竞品里不够吸引人;
  • 价格明显没有竞争力。

可以先优化标题和主图;长期仍然没有点击的商品,考虑用自定义标签单独分组测试,或者从 PMax 里排除。注意,这张表只包含有展示的商品,完全没有展示的商品不会出现在这里。

9. 搜索字词

PMax 触发的搜索字词,按费用从高到低排序,最后一列给出建议:

  • 否定关键字候选:花费超过设置金额、没有转化的搜索字词。确认它和你的产品不相关(不是品牌词、不是核心词)后,可以在 PMax 里添加为否定关键字。PMax 的否定关键字只对搜索和购物流量生效。
  • 表现好:ROAS 高于目标的搜索字词,可以考虑在搜索广告里单独投放,获得更多控制。

否定关键字的添加方法和匹配方式,见 否定关键字怎么加。

10. 使用说明和运行日志

「使用说明」记录了这次运行的时间范围和你设置的盈亏线;「运行日志」记录每一步是否成功。某张表没有数据或显示「这个报告本次没有生成」时,先看日志里的原因。

建议的使用节奏

频率做什么
每周看行动清单的「高」优先级问题;看渠道拆分和每日趋势有没有异常
每两周处理花钱无转化和亏损的商品;检查搜索字词,添加否定关键字
每月替换评级较差的素材;看 180 天商品表,调整商品分组;处理僵尸商品

每次调整后记下日期,下次运行时对比效果。PMax 每次大的调整都可能重新进入学习,不要同时改太多东西。

常见问题

运行时报错,提示某个字段不认识

Google Ads 的报告字段会随 API 版本更新。升级版脚本对新功能做了兼容处理:如果你的账户暂不支持渠道拆分、素材级数据或搜索字词报告,脚本会自动改用简化查询或跳过这一项,并在对应的工作表和运行日志里写明原因,其他报告照常生成。

渠道拆分只显示「跨网络(未拆分)」

渠道数据从 2025 年 6 月才开始提供,而且需要较新的 API 版本。如果你的时间范围早于这个日期,或者账户暂时不支持,就会出现这种情况。

数据和 Google Ads 后台对不上

常见原因有三个:时间范围不同(脚本统计到昨天为止);转化按点击日期统计,后台如果选的是按转化日期就会不同;PMax 的部分数据(例如搜索字词)有隐私阈值,数量太少的不会显示。小幅差异是正常的。

运行超时

Google Ads 脚本单次最长运行 30 分钟。商品非常多的账户,可以把 MAX_ROWS 调小,或者用 CAMPAIGN_NAME_CONTAINS 只分析部分广告系列。

能不能在经理账户(MCC)里批量运行

这个版本是给单个广告账户用的,需要在每个账户里分别安装。

表格可以分享给同事或客户吗

可以。表格保存在你的 Google 云端硬盘里,和普通 Google 表格一样分享即可。

完整脚本

复制下面的全部代码,粘贴到 Google Ads 脚本编辑器里。只需要修改最上方「设置」部分,其他代码不需要改动。

/**
 * PMax 体检脚本 v2.0(毕瑞出海中文版)
 * ------------------------------------------------------------
 * 作用:把效果最大化广告(Performance Max)的数据导出到 Google 表格,
 *       自动计算 ROAS、CPA,按渠道 / 商品 / 素材 / 搜索字词拆开分析,
 *       并生成一份「行动清单」,告诉你先改哪里。
 *
 * 思路参考 Mike Rhodes 的免费 PMax 脚本 v28,代码为重新编写:
 *   - 不再依赖模板表格,工作表自动创建;
 *   - 使用 Google Ads API 的 PMax 渠道报告(segments.ad_network_type);
 *   - 新增素材级效果、搜索字词、行动清单、邮件通知;
 *   - 每一部分单独运行,某一个报告出错不会影响其他报告。
 *
 * 使用方法和每个工作表的解读,见:https://berightdigital.com/pmax-script-tutorial/
 * ------------------------------------------------------------
 */

// ======================= 设置(只需要改这里) =======================
var CONFIG = {
  // Google 表格网址。留空 = 第一次运行时自动新建一份,网址会打印在「日志」里,
  // 把它复制回这里,之后每次运行都会更新同一份表格。
  SPREADSHEET_URL: '',

  // 只分析名称包含这段文字的 PMax 广告系列。留空 = 全部 PMax 广告系列。
  CAMPAIGN_NAME_CONTAINS: '',

  // 时间范围(天,截至昨天)
  DAYS: 30,          // 大部分报告的时间范围
  LONG_DAYS: 180,    // 商品长期表现
  ZOMBIE_DAYS: 365,  // 僵尸商品(有展示、零点击)的判断范围

  // 你的盈亏线。盈亏平衡 ROAS = 1 ÷ 毛利率(扣除商品成本、运费、手续费后)。
  // 不会算可以用 https://berightdigital.com/tools/roas-calculator/
  BREAKEVEN_ROAS: 2.5,
  TARGET_ROAS: 3.5,

  // 花费超过这个金额仍然没有转化,就标记为「花钱无转化」。建议设为你可接受的单次转化费用。
  WASTE_COST: 50,

  // 僵尸商品:展示次数至少多少才列出(过滤掉几乎没展示的商品)
  ZOMBIE_MIN_IMPRESSIONS: 100,

  // 每个表最多写入多少行(防止账户太大时超时)
  MAX_ROWS: 5000,

  // 运行完成后把行动清单摘要发到这个邮箱。留空 = 不发邮件。多个邮箱用英文逗号分隔。
  EMAIL: ''
};
// ======================= 设置结束,下面的代码一般不需要改 =======================


var VERSION = 'v2.0';
var RUN_LOG = [];
var FMT = { money: '#,##0.00', int: '#,##0', pct: '0.00%', ratio: '0.00', dec: '#,##0.00' };

var CHANNEL_CN = {
  SEARCH: '搜索', SEARCH_PARTNERS: '搜索网络合作伙伴', CONTENT: '展示广告网络', DISPLAY: '展示广告网络',
  YOUTUBE: 'YouTube', YOUTUBE_SEARCH: 'YouTube', YOUTUBE_WATCH: 'YouTube', GMAIL: 'Gmail',
  DISCOVER: 'Discover', MAPS: '地图', GOOGLE_TV: 'Google TV', MIXED: '跨网络(未拆分)',
  CROSS_NETWORK: '跨网络(未拆分)', UNKNOWN: '未知', UNSPECIFIED: '未指定'
};
var LABEL_CN = { BEST: '最佳', GOOD: '良好', LOW: '较差', LEARNING: '学习中', PENDING: '待定', UNKNOWN: '未知', UNSPECIFIED: '' };
var STRENGTH_CN = { EXCELLENT: '极佳', GOOD: '良好', AVERAGE: '一般', POOR: '差', PENDING: '待定', NO_ADS: '无广告', UNKNOWN: '未知', UNSPECIFIED: '' };
var STATUS_CN = { ENABLED: '已启用', PAUSED: '已暂停', REMOVED: '已移除', ELIGIBLE: '符合条件', LIMITED: '受限', NOT_ELIGIBLE: '不符合条件', PENDING: '审核中', UNKNOWN: '未知', UNSPECIFIED: '' };
var FIELD_CN = {
  HEADLINE: '标题', LONG_HEADLINE: '长标题', DESCRIPTION: '说明', BUSINESS_NAME: '商家名称',
  MARKETING_IMAGE: '横向图片', SQUARE_MARKETING_IMAGE: '方形图片', PORTRAIT_MARKETING_IMAGE: '纵向图片',
  TALL_PORTRAIT_MARKETING_IMAGE: '竖长图片', LOGO: '徽标', LANDSCAPE_LOGO: '横向徽标', YOUTUBE_VIDEO: 'YouTube 视频',
  CALL_TO_ACTION_SELECTION: '行动号召', MEDIA_BUNDLE: '媒体包', SITELINK: '附加链接', CALLOUT: '宣传信息',
  STRUCTURED_SNIPPET: '结构化摘要', PRICE: '价格', PROMOTION: '促销', CALL: '电话', LEAD_FORM: '潜在客户表单'
};


function main() {
  var started = new Date();
  var acct = AdsApp.currentAccount();
  log('信息', '开始', '账户:' + acct.getName() + '(' + acct.getCustomerId() + '),版本 ' + VERSION);

  var ss = openSpreadsheet(acct);
  var R = {}; // 每个报告的结果,供行动清单使用

  var pS = '商品_近' + CONFIG.DAYS + '天', pL = '商品_近' + CONFIG.LONG_DAYS + '天';
  R.camps    = safeSheet(ss, '广告系列概览', function () { return reportCampaigns(ss); });
  R.channels = safeSheet(ss, '渠道拆分',     function () { return reportChannels(ss); });
  R.daily    = safeSheet(ss, '每日趋势',     function () { return reportDaily(ss); });
  R.ags      = safeSheet(ss, '素材资源组',   function () { return reportAssetGroups(ss); });
  R.assets   = safeSheet(ss, '素材明细',     function () { return reportAssets(ss); });
  R.prods    = safeSheet(ss, pS,             function () { return reportProducts(ss, CONFIG.DAYS, pS); });
  R.prodsL   = safeSheet(ss, pL,             function () { return reportProducts(ss, CONFIG.LONG_DAYS, pL); });
  R.zombies  = safeSheet(ss, '僵尸商品',     function () { return reportZombies(ss); });
  R.terms    = safeSheet(ss, '搜索字词',     function () { return reportSearchTerms(ss); });

  var actions = safe('行动清单', function () { return buildActions(ss, R); }) || [];
  safe('使用说明', function () { writeGuide(ss, acct, started); });

  log('信息', '完成', '用时 ' + Math.round((new Date() - started) / 1000) + ' 秒,表格:' + ss.getUrl());
  writeLog(ss);
  orderSheets(ss);
  if (CONFIG.EMAIL) safe('邮件', function () { sendEmail(acct, ss, actions, R.camps); });
}


// ======================= 各个报告 =======================

function reportCampaigns(ss) {
  var q = 'SELECT campaign.id, campaign.name, campaign.status, campaign.bidding_strategy_type, ' +
          'campaign_budget.amount_micros, metrics.impressions, metrics.clicks, metrics.cost_micros, ' +
          'metrics.conversions, metrics.conversions_value ' +
          'FROM campaign WHERE ' + range(CONFIG.DAYS) + pmaxFilter() + ' AND campaign.status != "REMOVED"';
  var list = runQuery(q).map(function (r) {
    var m = metricsOf(r);
    m.id = g(r, 'campaign.id'); m.name = g(r, 'campaign.name');
    m.status = g(r, 'campaign.status'); m.bidding = g(r, 'campaign.biddingStrategyType');
    m.budget = money(g(r, 'campaignBudget.amountMicros'));
    return m;
  });
  list.sort(byCost);
  var header = ['广告系列', '状态', '出价策略', '日预算', '费用', '展示', '点击', '点击率', '平均点击费用',
                '转化', '转化价值', 'ROAS', '单次转化费用', '判断'];
  var data = list.map(function (c) {
    return [c.name, cn(STATUS_CN, c.status), c.bidding, c.budget, c.cost, c.impr, c.clicks, div(c.clicks, c.impr),
            div(c.cost, c.clicks), c.conv, c.value, div(c.value, c.cost), div(c.cost, c.conv), judge(c)];
  });
  var tot = sumMetrics(list);
  data.push(['合计', '', '', '', tot.cost, tot.impr, tot.clicks, div(tot.clicks, tot.impr), div(tot.cost, tot.clicks),
             tot.conv, tot.value, div(tot.value, tot.cost), div(tot.cost, tot.conv), judge(tot)]);
  var sh = write(ss, '广告系列概览', header, data,
    ['', '', '', FMT.money, FMT.money, FMT.int, FMT.int, FMT.pct, FMT.money, FMT.dec, FMT.money, FMT.ratio, FMT.money, '']);
  colorJudge(sh, data, 14);
  log('信息', '广告系列概览', list.length + ' 个 PMax 广告系列');
  return { list: list, total: tot };
}

function reportChannels(ss) {
  var base = 'metrics.impressions, metrics.clicks, metrics.cost_micros, metrics.conversions, metrics.conversions_value ' +
             'FROM campaign WHERE ' + range(CONFIG.DAYS) + pmaxFilter() + ' AND campaign.status != "REMOVED"';
  var rows, withProduct = true;
  try {
    rows = runQuery('SELECT campaign.name, segments.ad_network_type, segments.ad_using_product_data, ' + base);
  } catch (e) {
    withProduct = false;
    log('提示', '渠道拆分', '不支持「是否使用商品数据」细分,改为只按渠道拆分:' + e.message);
    rows = runQuery('SELECT campaign.name, segments.ad_network_type, ' + base);
  }

  var map = {};
  rows.forEach(function (r) {
    var ch = cn(CHANNEL_CN, g(r, 'segments.adNetworkType'));
    if (withProduct) ch += String(g(r, 'segments.adUsingProductData')) === 'true' ? '(商品广告)' : '';
    var key = g(r, 'campaign.name') + '||' + ch;
    if (!map[key]) map[key] = zero({ campaign: g(r, 'campaign.name'), channel: ch });
    addMetrics(map[key], metricsOf(r));
  });
  var list = values(map);

  // 每个广告系列的费用合计,用于计算占比;同时汇总全账户
  var campCost = {}, acctMap = {};
  list.forEach(function (x) {
    campCost[x.campaign] = (campCost[x.campaign] || 0) + x.cost;
    if (!acctMap[x.channel]) acctMap[x.channel] = zero({ campaign: '全部 PMax 合计', channel: x.channel });
    addMetrics(acctMap[x.channel], x);
  });
  var acctList = values(acctMap), acctCost = 0;
  acctList.forEach(function (x) { acctCost += x.cost; });
  campCost['全部 PMax 合计'] = acctCost;
  acctList.sort(byCost);
  list.sort(function (a, b) { return a.campaign < b.campaign ? -1 : a.campaign > b.campaign ? 1 : b.cost - a.cost; });

  var unsplit = list.length > 0 && list.every(function (x) { return x.channel.indexOf('跨网络') === 0; });
  if (unsplit) log('提示', '渠道拆分', '当前只返回「跨网络」汇总,渠道明细不可用(渠道数据从 2025 年 6 月起才有)。');

  var header = ['广告系列', '渠道', '费用', '费用占比', '展示', '点击', '点击率', '转化', '转化价值', 'ROAS', '单次转化费用', '判断'];
  var all = acctList.concat(list);
  var data = all.map(function (x) {
    return [x.campaign, x.channel, x.cost, div(x.cost, campCost[x.campaign]), x.impr, x.clicks, div(x.clicks, x.impr),
            x.conv, x.value, div(x.value, x.cost), div(x.cost, x.conv), judge(x)];
  });
  var sh = write(ss, '渠道拆分', header, data,
    ['', '', FMT.money, FMT.pct, FMT.int, FMT.int, FMT.pct, FMT.dec, FMT.money, FMT.ratio, FMT.money, '']);
  colorJudge(sh, data, 12);
  if (acctList.length) sh.getRange(2, 1, acctList.length, header.length).setFontWeight('bold');
  return { list: list, account: acctList, campCost: campCost, unsplit: unsplit };
}

function reportDaily(ss) {
  var q = 'SELECT segments.date, campaign.name, metrics.impressions, metrics.clicks, metrics.cost_micros, ' +
          'metrics.conversions, metrics.conversions_value FROM campaign WHERE ' + range(CONFIG.DAYS) + pmaxFilter() +
          ' AND campaign.status != "REMOVED" ORDER BY segments.date';
  var list = runQuery(q).map(function (r) {
    var m = metricsOf(r); m.date = g(r, 'segments.date'); m.name = g(r, 'campaign.name'); return m;
  });
  var header = ['日期', '广告系列', '费用', '展示', '点击', '转化', '转化价值', 'ROAS'];
  var data = list.map(function (d) { return [d.date, d.name, d.cost, d.impr, d.clicks, d.conv, d.value, div(d.value, d.cost)]; });
  write(ss, '每日趋势', header, data, ['', '', FMT.money, FMT.int, FMT.int, FMT.dec, FMT.money, FMT.ratio]);
  return list;
}

function reportAssetGroups(ss) {
  var q = 'SELECT campaign.name, asset_group.id, asset_group.name, asset_group.status, asset_group.ad_strength, ' +
          'metrics.impressions, metrics.clicks, metrics.cost_micros, metrics.conversions, metrics.conversions_value ' +
          'FROM asset_group WHERE ' + range(CONFIG.DAYS) + pmaxFilter() + ' AND asset_group.status != "REMOVED"';
  var list = runQuery(q).map(function (r) {
    var m = metricsOf(r);
    m.campaign = g(r, 'campaign.name'); m.id = g(r, 'assetGroup.id'); m.name = g(r, 'assetGroup.name');
    m.status = g(r, 'assetGroup.status'); m.strength = g(r, 'assetGroup.adStrength');
    return m;
  });
  list.sort(byCost);
  var header = ['广告系列', '素材资源组', '状态', '广告效力', '费用', '展示', '点击', '点击率', '转化', '转化价值', 'ROAS', '单次转化费用', '判断'];
  var data = list.map(function (a) {
    return [a.campaign, a.name, cn(STATUS_CN, a.status), cn(STRENGTH_CN, a.strength), a.cost, a.impr, a.clicks,
            div(a.clicks, a.impr), a.conv, a.value, div(a.value, a.cost), div(a.cost, a.conv), judge(a)];
  });
  var sh = write(ss, '素材资源组', header, data,
    ['', '', '', '', FMT.money, FMT.int, FMT.int, FMT.pct, FMT.dec, FMT.money, FMT.ratio, FMT.money, '']);
  colorJudge(sh, data, 13);
  return list;
}

function reportAssets(ss) {
  var attrs = 'campaign.name, asset_group.name, asset_group_asset.field_type, asset_group_asset.status, ' +
              'asset_group_asset.performance_label, asset.id, asset.type, asset.name, asset.text_asset.text, ' +
              'asset.youtube_video_asset.youtube_video_title, asset.youtube_video_asset.youtube_video_id, ' +
              'asset.image_asset.full_size.url';
  var where = ' FROM asset_group_asset WHERE campaign.advertising_channel_type = "PERFORMANCE_MAX"' + nameFilter() +
              ' AND campaign.status != "REMOVED" AND asset_group.status != "REMOVED" AND asset_group_asset.status != "REMOVED"';
  var metricsSel = ', metrics.impressions, metrics.clicks, metrics.cost_micros, metrics.conversions, metrics.conversions_value';

  // 依次尝试:素材级效果 + 投放状态 → 素材级效果 → 只要素材属性(老版本 API)
  var attempts = [
    { sel: attrs + ', asset_group_asset.primary_status' + metricsSel, w: where + ' AND ' + range(CONFIG.DAYS), metrics: true, ps: true },
    { sel: attrs + metricsSel, w: where + ' AND ' + range(CONFIG.DAYS), metrics: true, ps: false },
    { sel: attrs, w: where, metrics: false, ps: false }
  ];
  var rows = null, used = null;
  for (var i = 0; i < attempts.length && rows === null; i++) {
    try { rows = runQuery('SELECT ' + attempts[i].sel + attempts[i].w); used = attempts[i]; }
    catch (e) { log('提示', '素材明细', '第 ' + (i + 1) + ' 种查询不可用,改用简化查询:' + e.message); }
  }
  if (rows === null) throw new Error('素材查询全部失败');

  var list = rows.map(function (r) {
    var m = used.metrics ? metricsOf(r) : zero({});
    m.campaign = g(r, 'campaign.name'); m.group = g(r, 'assetGroup.name');
    m.field = g(r, 'assetGroupAsset.fieldType'); m.label = g(r, 'assetGroupAsset.performanceLabel');
    m.primary = used.ps ? g(r, 'assetGroupAsset.primaryStatus') : '';
    m.type = g(r, 'asset.type'); m.assetId = g(r, 'asset.id');
    m.content = g(r, 'asset.textAsset.text') || g(r, 'asset.youtubeVideoAsset.youtubeVideoTitle') || g(r, 'asset.name') || '';
    var yt = g(r, 'asset.youtubeVideoAsset.youtubeVideoId');
    m.link = yt ? 'https://www.youtube.com/watch?v=' + yt : (g(r, 'asset.imageAsset.fullSize.url') || '');
    return m;
  });
  list.sort(function (a, b) {
    return a.campaign + a.group < b.campaign + b.group ? -1 : a.campaign + a.group > b.campaign + b.group ? 1 : b.cost - a.cost;
  });
  var header = ['广告系列', '素材资源组', '素材位置', '素材类型', '内容 / 标题', '链接', '效果评级', '投放状态',
                '费用', '展示', '点击', '点击率', '转化', '转化价值', 'ROAS', '素材 ID'];
  var data = list.map(function (a) {
    return [a.campaign, a.group, cn(FIELD_CN, a.field), a.type, String(a.content).slice(0, 300), a.link,
            cn(LABEL_CN, a.label), cn(STATUS_CN, a.primary), used.metrics ? a.cost : '', used.metrics ? a.impr : '',
            used.metrics ? a.clicks : '', used.metrics ? div(a.clicks, a.impr) : '', used.metrics ? a.conv : '',
            used.metrics ? a.value : '', used.metrics ? div(a.value, a.cost) : '', String(a.assetId)];
  });
  write(ss, '素材明细', header, cap(data, '素材明细'),
    ['', '', '', '', '', '', '', '', FMT.money, FMT.int, FMT.int, FMT.pct, FMT.dec, FMT.money, FMT.ratio, '@']);
  if (!used.metrics) log('提示', '素材明细', '当前 API 版本没有素材级效果数据,只导出了素材属性和评级。');
  return { list: list, hasMetrics: used.metrics };
}

function reportProducts(ss, days, sheetName) {
  var q = 'SELECT segments.product_item_id, segments.product_title, segments.product_custom_attribute0, ' +
          'segments.product_custom_attribute1, segments.product_custom_attribute2, segments.product_custom_attribute3, ' +
          'segments.product_custom_attribute4, metrics.impressions, metrics.clicks, metrics.cost_micros, ' +
          'metrics.conversions, metrics.conversions_value FROM shopping_performance_view WHERE ' + range(days) + pmaxFilter();
  var map = {};
  runQuery(q).forEach(function (r) {
    var id = String(g(r, 'segments.productItemId') || '');
    if (!map[id]) map[id] = zero({
      id: id, title: g(r, 'segments.productTitle') || '',
      c0: g(r, 'segments.productCustomAttribute0') || '', c1: g(r, 'segments.productCustomAttribute1') || '',
      c2: g(r, 'segments.productCustomAttribute2') || '', c3: g(r, 'segments.productCustomAttribute3') || '',
      c4: g(r, 'segments.productCustomAttribute4') || ''
    });
    addMetrics(map[id], metricsOf(r));
  });
  var list = values(map);
  list.forEach(function (p) { p.cls = productClass(p); });
  list.sort(byCost);

  var header = ['商品 ID', '商品标题', '自定义标签0', '自定义标签1', '自定义标签2', '自定义标签3', '自定义标签4',
                '费用', '展示', '点击', '点击率', '转化', '转化价值', 'ROAS', '单次转化费用', '分类'];
  var data = list.map(function (p) {
    return [p.id, p.title, p.c0, p.c1, p.c2, p.c3, p.c4, p.cost, p.impr, p.clicks, div(p.clicks, p.impr),
            p.conv, p.value, div(p.value, p.cost), div(p.cost, p.conv), p.cls];
  });
  data = cap(data, sheetName);
  var sh = write(ss, sheetName, header, data,
    ['@', '', '', '', '', '', '', FMT.money, FMT.int, FMT.int, FMT.pct, FMT.dec, FMT.money, FMT.ratio, FMT.money, '']);
  colorJudge(sh, data, 16);
  log('信息', sheetName, list.length + ' 个商品');
  return list;
}

function reportZombies(ss) {
  var q = 'SELECT segments.product_item_id, segments.product_title, segments.product_custom_attribute0, ' +
          'metrics.impressions, metrics.clicks, metrics.cost_micros FROM shopping_performance_view WHERE ' +
          range(CONFIG.ZOMBIE_DAYS) + pmaxFilter() + ' AND metrics.clicks = 0 AND metrics.impressions >= ' +
          Number(CONFIG.ZOMBIE_MIN_IMPRESSIONS) + ' ORDER BY metrics.impressions DESC';
  var map = {};
  runQuery(q).forEach(function (r) {
    var id = String(g(r, 'segments.productItemId') || '');
    if (!map[id]) map[id] = zero({ id: id, title: g(r, 'segments.productTitle') || '', c0: g(r, 'segments.productCustomAttribute0') || '' });
    addMetrics(map[id], metricsOf(r));
  });
  var list = values(map).filter(function (z) { return z.clicks === 0; });
  list.sort(function (a, b) { return b.impr - a.impr; });
  var header = ['商品 ID', '商品标题', '自定义标签0', '展示(近' + CONFIG.ZOMBIE_DAYS + '天)', '点击', '建议'];
  var data = list.map(function (z) {
    return [z.id, z.title, z.c0, z.impr, z.clicks, '检查标题、主图和价格;或用自定义标签单独分组测试'];
  });
  write(ss, '僵尸商品', header, cap(data, '僵尸商品'), ['@', '', '', FMT.int, FMT.int, '']);
  log('信息', '僵尸商品', list.length + ' 个');
  return list;
}

function reportSearchTerms(ss) {
  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) + pmaxFilter() + ' AND campaign.status != "REMOVED" AND metrics.impressions > 0 ' +
          'ORDER BY metrics.cost_micros DESC LIMIT ' + Number(CONFIG.MAX_ROWS);
  var list = runQuery(q).map(function (r) {
    var m = metricsOf(r); m.campaign = g(r, 'campaign.name'); m.term = g(r, 'campaignSearchTermView.searchTerm') || '';
    return m;
  });
  var header = ['广告系列', '搜索字词', '费用', '展示', '点击', '点击率', '转化', '转化价值', 'ROAS', '建议'];
  var data = list.map(function (t) {
    var tip = (t.cost >= CONFIG.WASTE_COST && t.conv === 0) ? '否定关键字候选(先确认不是品牌词或核心词)' :
              (t.conv > 0 && t.cost > 0 && t.value / t.cost >= CONFIG.TARGET_ROAS) ? '表现好,可以考虑在搜索广告里单独投放' : '';
    return [t.campaign, t.term, t.cost, t.impr, t.clicks, div(t.clicks, t.impr), t.conv, t.value, div(t.value, t.cost), tip];
  });
  write(ss, '搜索字词', header, data, ['', '', FMT.money, FMT.int, FMT.int, FMT.pct, FMT.dec, FMT.money, FMT.ratio, '']);
  log('信息', '搜索字词', list.length + ' 个搜索字词');
  return list;
}


// ======================= 行动清单 =======================

function buildActions(ss, R) {
  var A = []; // [优先级, 类别, 对象, 发现, 数据, 建议]
  var be = CONFIG.BREAKEVEN_ROAS, tg = CONFIG.TARGET_ROAS, waste = CONFIG.WASTE_COST;

  if (R.camps) {
    R.camps.list.forEach(function (c) {
      var roas = c.cost > 0 ? c.value / c.cost : 0;
      if (c.cost >= waste && roas < be)
        A.push(['高', '广告系列', c.name, 'ROAS 低于盈亏平衡线', 'ROAS ' + r2(roas) + ',盈亏线 ' + be + ',费用 ' + r2(c.cost),
                '先看「渠道拆分」和「商品」表找出亏损来源;价值出价可适当提高目标 ROAS,或把亏损商品移出这个广告系列']);
      else if (c.cost >= waste && roas >= tg)
        A.push(['中', '广告系列', c.name, 'ROAS 高于目标', 'ROAS ' + r2(roas) + ',目标 ' + tg,
                '如果预算经常花完,可以每次加 15%–20% 预算,加完观察 5–7 天']);
    });
  }

  if (R.channels && !R.channels.unsplit) {
    R.channels.list.forEach(function (x) {
      var share = R.channels.campCost[x.campaign] ? x.cost / R.channels.campCost[x.campaign] : 0;
      var roas = x.cost > 0 ? x.value / x.cost : 0;
      if (share >= 0.2 && x.cost >= waste && roas < be)
        A.push(['中', '渠道', x.campaign + ' / ' + x.channel, '花费占比高但 ROAS 低于盈亏线',
                '占广告系列费用 ' + Math.round(share * 100) + '%,ROAS ' + r2(roas),
                x.channel.indexOf('YouTube') >= 0 ? '检查视频素材:没有上传视频时系统会自动生成,质量往往较差,建议上传自己的竖版和横版视频' :
                x.channel.indexOf('展示') >= 0 ? '检查图片素材质量,查看「投放位置」报告并在账户层级排除低质量网站和应用' :
                '查看「搜索字词」表,把不相关的搜索加为否定关键字']);
    });
  } else if (R.channels && R.channels.unsplit) {
    A.push(['低', '渠道', '全部', '渠道明细不可用', '只返回跨网络汇总', '渠道数据从 2025 年 6 月起才有;时间范围选得太早或 API 版本较旧时会出现这种情况']);
  }

  var prods = R.prods || [];
  prods.filter(function (p) { return p.cls === '花钱无转化'; }).slice(0, 10).forEach(function (p) {
    A.push(['高', '商品', label(p), '花钱无转化', '费用 ' + r2(p.cost) + ',点击 ' + p.clicks,
            '检查价格、库存和落地页;确认不是新品后,用自定义标签把它移到单独的素材资源组或排除']);
  });
  prods.filter(function (p) { return p.cls === '亏损'; }).slice(0, 10).forEach(function (p) {
    A.push(['中', '商品', label(p), 'ROAS 低于盈亏平衡线', 'ROAS ' + r2(p.value / p.cost) + ',费用 ' + r2(p.cost),
            '检查毛利是否太低、价格是否有竞争力;可以按毛利给商品打自定义标签,分组设置不同目标']);
  });
  prods.filter(function (p) { return p.cls === '达标' && p.conv >= 2; })
       .sort(function (a, b) { return b.value / b.cost - a.value / a.cost; }).slice(0, 5).forEach(function (p) {
    A.push(['中', '商品', label(p), '表现好的商品', 'ROAS ' + r2(p.value / p.cost) + ',转化 ' + r2(p.conv),
            '考虑单独建素材资源组,配专属图片和文案,争取更多曝光']);
  });

  if (R.zombies && R.zombies.length) {
    var zi = 0; R.zombies.forEach(function (z) { zi += z.impr; });
    A.push(['中', '商品', R.zombies.length + ' 个僵尸商品', '近 ' + CONFIG.ZOMBIE_DAYS + ' 天有展示、零点击',
            '合计展示 ' + zi, '见「僵尸商品」表:优化标题前几个词、主图和价格;长期无点击的考虑下架或单独分组']);
  }

  (R.ags || []).forEach(function (a) {
    if (a.strength === 'POOR' || a.strength === 'AVERAGE')
      A.push([a.strength === 'POOR' ? '高' : '中', '素材资源组', a.campaign + ' / ' + a.name, '广告效力' + cn(STRENGTH_CN, a.strength),
              '费用 ' + r2(a.cost), '补齐标题、长标题、说明、横版/方形/纵向图片和视频,标题之间不要重复']);
  });

  if (R.assets) {
    var low = {}, bad = [];
    R.assets.list.forEach(function (x) {
      var k = x.campaign + ' / ' + x.group;
      if (x.label === 'LOW') low[k] = (low[k] || 0) + 1;
      if (x.primary === 'NOT_ELIGIBLE' || x.primary === 'LIMITED') bad.push(x);
    });
    Object.keys(low).forEach(function (k) {
      A.push(['低', '素材', k, low[k] + ' 个素材评级为「较差」', '', '保留至少 3–4 个同类型素材后,替换评级较差的素材']);
    });
    bad.slice(0, 20).forEach(function (x) {
      A.push(['高', '素材', x.campaign + ' / ' + x.group + ' / ' + cn(FIELD_CN, x.field), '素材' + cn(STATUS_CN, x.primary),
              String(x.content).slice(0, 60), '在 Google Ads 素材列表里查看受限原因(常见为政策审核),修改或替换']);
    });
  }

  if (R.terms) {
    var w = R.terms.filter(function (t) { return t.cost >= waste && t.conv === 0; });
    if (w.length) {
      var wc = 0; w.forEach(function (t) { wc += t.cost; });
      A.push(['中', '搜索字词', w.length + ' 个搜索字词', '花钱无转化', '合计费用 ' + r2(wc) + ',如:' +
              w.slice(0, 5).map(function (t) { return t.term; }).join('、'),
              '见「搜索字词」表,确认不相关后加为 PMax 否定关键字(只作用于搜索和购物流量)']);
    }
  }

  if (!prods.length) A.push(['低', '商品', '全部', '没有商品数据', '', '如果是零售 PMax,检查 Merchant Center 是否已关联、商品是否已获批']);

  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);
  var bg = A.map(function (a) { return [a[0] === '高' ? '#fde2e1' : a[0] === '中' ? '#fff1d6' : '#eef1f5']; });
  sh.getRange(2, 1, A.length, 1).setBackgrounds(bg);
  sh.setColumnWidth(3, 260); sh.setColumnWidth(4, 200); sh.setColumnWidth(5, 260); sh.setColumnWidth(6, 420);
  sh.getRange(2, 1, A.length, header.length).setWrap(true).setVerticalAlignment('top');
  log('信息', '行动清单', A.length + ' 条');
  return A;
}


// ======================= 说明、日志、邮件 =======================

function writeGuide(ss, acct, started) {
  var rows = [
    ['PMax 体检报告(' + VERSION + ')', ''],
    ['账户', acct.getName() + '(' + acct.getCustomerId() + ')'],
    ['运行时间', Utilities.formatDate(started, acct.getTimeZone(), 'yyyy-MM-dd HH:mm')],
    ['时间范围', '近 ' + CONFIG.DAYS + ' 天(截至昨天);商品长期 ' + CONFIG.LONG_DAYS + ' 天;僵尸商品 ' + CONFIG.ZOMBIE_DAYS + ' 天'],
    ['盈亏平衡 ROAS / 目标 ROAS', CONFIG.BREAKEVEN_ROAS + ' / ' + CONFIG.TARGET_ROAS],
    ['花钱无转化的标准', '费用 ≥ ' + CONFIG.WASTE_COST + ' 且转化 = 0'],
    ['', ''],
    ['工作表', '看什么'],
    ['行动清单', '按优先级列出需要处理的问题,先看这里'],
    ['广告系列概览', '每个 PMax 广告系列的费用、转化、ROAS 和判断'],
    ['渠道拆分', '费用花在搜索、YouTube、展示、Gmail、Discover、地图的比例和各自的 ROAS'],
    ['每日趋势', '每天的费用、转化价值和 ROAS,用来看波动和学习期'],
    ['素材资源组', '每个素材资源组的广告效力和效果'],
    ['素材明细', '每个标题、图片、视频的评级、投放状态和效果'],
    ['商品_近' + CONFIG.DAYS + '天 / 商品_近' + CONFIG.LONG_DAYS + '天', '每个商品的效果和分类:花钱无转化 / 亏损 / 保本到目标之间 / 达标 / 数据不足 / 无点击'],
    ['僵尸商品', '长期有展示、零点击的商品'],
    ['搜索字词', 'PMax 触发的搜索字词和效果,标出否定关键字候选'],
    ['运行日志', '每一步是否成功,出错时看这里'],
    ['', ''],
    ['判断规则', 'ROAS < 盈亏线 = 亏损;盈亏线 ≤ ROAS < 目标 = 保本到目标之间;ROAS ≥ 目标 = 达标'],
    ['注意', '转化按点击日期统计,最近几天的数据会陆续补上;转化价值以 Google Ads 回传为准,不含退货'],
    ['教程', 'https://berightdigital.com/pmax-script-tutorial/']
  ];
  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, actions, camps) {
  var lines = ['PMax 体检报告:' + acct.getName(), ''];
  if (camps && camps.total) {
    var t = camps.total;
    lines.push('近 ' + CONFIG.DAYS + ' 天:费用 ' + r2(t.cost) + ',转化价值 ' + r2(t.value) + ',ROAS ' + r2(t.cost ? t.value / t.cost : 0) +
               '(盈亏线 ' + CONFIG.BREAKEVEN_ROAS + ',目标 ' + CONFIG.TARGET_ROAS + ')', '');
  }
  lines.push('优先处理:');
  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, 'PMax 体检报告 - ' + acct.getName(), lines.join('\n'));
  log('信息', '邮件', '已发送到 ' + CONFIG.EMAIL);
}


// ======================= 工具函数 =======================

function openSpreadsheet(acct) {
  if (CONFIG.SPREADSHEET_URL) return SpreadsheetApp.openByUrl(CONFIG.SPREADSHEET_URL);
  var ss = SpreadsheetApp.create('PMax 体检报告 - ' + 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 safeSheet(ss, name, fn) {
  try { return fn(); }
  catch (e) {
    var msg = (e && e.message) ? e.message : String(e);
    log('错误', name, msg);
    try {
      var sh = ss.getSheetByName(name) || ss.insertSheet(name);
      sh.clear();
      sh.getRange(1, 1, 3, 1).setValues([['这个报告本次没有生成'], ['原因:' + msg],
        ['常见原因:账户的 Google Ads API 版本暂不支持这个报告,或者这个时间范围没有数据。其他报告不受影响。']]);
    } catch (e2) { /* 忽略写表错误 */ }
    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 nameFilter() {
  var s = String(CONFIG.CAMPAIGN_NAME_CONTAINS || '').replace(/["\\%]/g, '');
  return s ? ' AND campaign.name LIKE "%' + s + '%"' : '';
}
function pmaxFilter() { return ' AND campaign.advertising_channel_type = "PERFORMANCE_MAX"' + nameFilter(); }

// 按点号路径取值,例如 g(row, 'metrics.costMicros')
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 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 byCost(a, b) { return b.cost - a.cost; }
function label(p) { return (p.title || '(无标题)').slice(0, 60) + '(' + p.id + ')'; }

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 sumMetrics(list) { var t = zero({}); list.forEach(function (x) { addMetrics(t, x); }); return t; }

function judge(x) {
  if (x.cost <= 0) return '';
  if (x.conv === 0) return x.cost >= CONFIG.WASTE_COST ? '花钱无转化' : '数据不足';
  var roas = x.value / x.cost;
  if (roas < CONFIG.BREAKEVEN_ROAS) return '亏损';
  if (roas < CONFIG.TARGET_ROAS) return '保本到目标之间';
  return '达标';
}
function productClass(p) {
  if (p.cost <= 0) return p.impr > 0 && p.clicks === 0 ? '无点击' : '';
  return judge(p);
}

function cap(data, name) {
  if (data.length > CONFIG.MAX_ROWS) {
    log('提示', name, '共 ' + data.length + ' 行,只写入费用最高的 ' + CONFIG.MAX_ROWS + ' 行(可在 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 ? '(没有数据)' : ''; })];
  // 先设格式再写值,商品 ID 等文本列才不会被转成数字
  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;
}

// 以 = + - @ 开头的文字(例如搜索字词「+ size shoes」)会被表格当成公式,前面加 ' 保持为文字
function cleanRow(row) {
  return row.map(function (v) {
    if (typeof v === 'string' && /^[=+\-@]/.test(v)) return "'" + v;
    return v === undefined || v === null ? '' : v;
  });
}

function colorJudge(sh, data, col) {
  if (!data.length) return;
  var colors = { '亏损': '#fde2e1', '花钱无转化': '#fde2e1', '保本到目标之间': '#fff1d6', '达标': '#e3f4e8' };
  sh.getRange(2, col, data.length, 1).setBackgrounds(data.map(function (r) { return [colors[r[col - 1]] || null]; }));
}

function orderSheets(ss) {
  var order = ['使用说明', '行动清单', '广告系列概览', '渠道拆分', '每日趋势', '素材资源组', '素材明细',
               '商品_近' + CONFIG.DAYS + '天', '商品_近' + CONFIG.LONG_DAYS + '天', '僵尸商品', '搜索字词', '运行日志'];
  order.forEach(function (n, i) {
    var sh = ss.getSheetByName(n);
    if (sh) { ss.setActiveSheet(sh); ss.moveActiveSheet(i + 1); }
  });
  ['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);
}

配合其他工具一起用

  • 把每个月的 PMax 数据填进我们的 Google Ads 分析模板,看长期趋势;
  • 各个指标的中文解释,可以在 电商与营销指标库 里查,完整的核心指标说明见 Google Ads 指标大全;
  • 如果你管理的账户很多,需要更系统的 PMax 和购物广告优化功能,可以看看 Optmyzr,它提供 PMax 的渠道、商品和搜索字词分析,以及自动化规则;需要把 Google Ads 数据和其他平台放在一起做报表,可以用 Supermetrics。

总结

  1. 这个脚本只读取数据、写入表格,不会改动你的广告。
  2. 安装前先算好盈亏平衡 ROAS 和目标 ROAS,填进设置。
  3. 每周先看行动清单,再看渠道拆分、商品和搜索字词。
  4. 每次只改一两个地方,记下日期,下次运行时对比效果。

本文目录

继续阅读

按主题查找下一篇指南。

进入知识库 →