AI Agents Marketing

撰写我的谷歌广告文案的 Claude 技能

Alejandro Rioja
Alejandro Rioja
3 分钟阅读
TL;DR

我构建了一个 Claude 技能,直接用 GAQL 查询谷歌广告 API,找出点击率弱或单次转化成本高的自适应搜索广告,并在谷歌精确的字符限制(30 和 90)内重写标题和说明。新广告以“已暂停”状态创建——我自己审核后再启用。真正关键的一点是:谷歌的字符限制才是真正的约束,而不是文案本身。一个不能正确计算字符数的模型,会生成被谷歌悄悄截断或拒绝的广告。

免费新闻通讯

每周三。28,400+ 读者。纯干货。

[运营者视角] 我已经有一个管理 Facebook 广告的 Claude 技能——那个构建在这里。谷歌广告是更难的续作,不是因为它的 API 更差,而是因为自适应搜索广告有 Facebook 没有的硬性字符限制:每个标题 30 个字符,每条说明 90 个字符。一个读起来顺畅但在投放时被拒绝或截断的重写,不是重写,是个 bug。这个技能的大部分工作,都花在强迫模型遵守这个约束上,而不是相信它会自动遵守。

Table of contents

Open Table of contents

为什么谷歌广告需要和 Facebook 不同的构建方式

Facebook 技能读取表现数据、重写文案、发布新的广告组。谷歌广告做同样三件事,但有两点不同:

  1. 查询语言是 GAQL,不是 REST 过滤器。 谷歌广告 API v18 运行在 Google Ads Query Language 之上——类似 SQL、只读,正是为这种报表提取而设计的。
  2. 自适应搜索广告有硬性限制。 每个广告最多 15 个标题(每个 30 个字符)和 4 条说明(每条 90 个字符)。谷歌不会温和地提醒你——它要么在提交时拒绝该素材,要么更糟,投放一个对真实顾客来说读起来不对劲的截断版本。

第 2 点正是让简单实现崩溃的地方。让模型”写一个更有力的标题”,它会欣然给你 34 个字符。谷歌广告会拒绝那个素材,或者把它渲染成在单词中间被截断的样子。我往往要等到看到一个以”立即预约您的—“结尾的真实广告才会发现。

获取 API 访问权限(所有人都低估的部分)

三件事,第三件如果不提前规划会耽误你一周。

  1. 一个带 OAuth2 凭证的 Google Cloud 项目——客户端 ID、客户端密钥,以及一个范围为 https://www.googleapis.com/auth/adwords 的刷新令牌。通过 OAuth playground 或一个简短的本地脚本生成一次刷新令牌;之后它就是一个存储的凭证,不再是登录流程。
  2. 开发者令牌——从你的谷歌广告管理(MCC)账户的”管理”→“API 中心”申请。
  3. 访问级别。 每个新的开发者令牌都从测试账户访问开始——它只能对谷歌广告的测试账户生效,不能用于你的真实账户。你需要通过一个由谷歌人工审核的表单申请基本访问权限(每天最多 15,000 次操作,足够这个用途)。在计划上线真实账户之前,预留几个工作日等待审批。

把 GOOGLE_ADS_CLIENT_ID、GOOGLE_ADS_CLIENT_SECRET、GOOGLE_ADS_REFRESH_TOKEN、GOOGLE_ADS_DEVELOPER_TOKEN 和 GOOGLE_ADS_CUSTOMER_ID(10 位数的账户 ID,不带连字符)存进你的 .env。如果你通过 MCC 管理账户,还需要把 GOOGLE_ADS_LOGIN_CUSTOMER_ID 设为管理账户的 ID——没有它,查询会因权限错误被拒绝,而错误信息不会告诉你原因。

技能的文件结构

code
.claude/skills/google-ads/
  SKILL.md          ← Claude 读取的指令
  index.ts          ← 实际的工具实现
  limits.ts         ← 字符限制验证

SKILL.md:

markdown
# Google Ads Manager Skill

Use this skill when the user says "check my Google Ads", "run ads
report", or "rewrite underperforming RSAs". Never run this without
explicit user instruction — it touches live ad spend.

## What it can do
- Pull campaign and ad group performance via GAQL (last 7 or 30 days)
- Flag responsive search ads with CTR < 3% or CPA above the stored
  target as underperformers
- Rewrite headlines (≤30 chars) and descriptions (≤90 chars) for
  flagged ads in Ale's voice
- Create new ad variations as PAUSED — never enabled automatically

## What it will NOT do
- Change budgets or bids without explicit confirmation
- Enable a paused ad
- Submit any headline or description that fails the character check

“What it will NOT do” 的最后一行是我在技能第一周发布了一个 32 字符的标题之后加上去的。模型自己的计数错了两个字符——近到一眼看过去能通过,远到足以让素材被拒绝。从那以后,所有内容都要经过一个严格的验证器,而不是模型的自我报告。

核心 TypeScript 代码

typescript
// .claude/skills/google-ads/index.ts
import { GoogleAdsApi } from "google-ads-api";
import Anthropic from "@anthropic-ai/sdk";

const client = new GoogleAdsApi({
  client_id: process.env.GOOGLE_ADS_CLIENT_ID!,
  client_secret: process.env.GOOGLE_ADS_CLIENT_SECRET!,
  developer_token: process.env.GOOGLE_ADS_DEVELOPER_TOKEN!,
});

const customer = client.Customer({
  customer_id: process.env.GOOGLE_ADS_CUSTOMER_ID!,
  login_customer_id: process.env.GOOGLE_ADS_LOGIN_CUSTOMER_ID,
  refresh_token: process.env.GOOGLE_ADS_REFRESH_TOKEN!,
});

interface AdPerformance {
  adGroupAdId: string;
  campaignName: string;
  headlines: string[];
  descriptions: string[];
  impressions: number;
  clicks: number;
  costMicros: number;
  conversions: number;
  ctr: number;
  cpa: number;
}

async function getAdPerformance(days = 7): Promise<AdPerformance[]> {
  const rows = await customer.query(`
    SELECT
      ad_group_ad.ad.id,
      campaign.name,
      ad_group_ad.ad.responsive_search_ad.headlines,
      ad_group_ad.ad.responsive_search_ad.descriptions,
      metrics.impressions,
      metrics.clicks,
      metrics.cost_micros,
      metrics.conversions
    FROM ad_group_ad
    WHERE segments.date DURING LAST_${days === 30 ? "30" : "7"}_DAYS
      AND ad_group_ad.status = 'ENABLED'
  `);

  return rows.map((r: any) => {
    const impressions = Number(r.metrics.impressions ?? 0);
    const clicks = Number(r.metrics.clicks ?? 0);
    const cost = Number(r.metrics.cost_micros ?? 0) / 1_000_000;
    const conversions = Number(r.metrics.conversions ?? 0);
    return {
      adGroupAdId: r.ad_group_ad.ad.id,
      campaignName: r.campaign.name,
      headlines: (r.ad_group_ad.ad.responsive_search_ad?.headlines ?? []).map((h: any) => h.text),
      descriptions: (r.ad_group_ad.ad.responsive_search_ad?.descriptions ?? []).map((d: any) => d.text),
      impressions,
      clicks,
      costMicros: Number(r.metrics.cost_micros ?? 0),
      conversions,
      ctr: impressions > 0 ? (clicks / impressions) * 100 : 0,
      cpa: conversions > 0 ? cost / conversions : Infinity,
    };
  });
}

const HEADLINE_MAX = 30;
const DESCRIPTION_MAX = 90;

function validateAsset(text: string, max: number): boolean {
  return text.length <= max;
}

async function rewriteRsa(
  ad: AdPerformance,
  targetCpa: number,
): Promise<{ headlines: string[]; descriptions: string[] }> {
  const anthropic = new Anthropic();
  const msg = await anthropic.messages.create({
    model: "claude-sonnet-4-6",
    max_tokens: 1024,
    messages: [{
      role: "user",
      content: `Rewrite this Google Ads responsive search ad in Alejandro Rioja's voice: direct, operator-focused, specific. It's underperforming — CTR ${ad.ctr.toFixed(2)}%, CPA $${ad.cpa.toFixed(2)} vs a $${targetCpa} target.

Current headlines: ${JSON.stringify(ad.headlines)}
Current descriptions: ${JSON.stringify(ad.descriptions)}

Rules, non-negotiable:
- Each headline: 30 characters MAXIMUM, including spaces and punctuation
- Each description: 90 characters MAXIMUM, including spaces and punctuation
- Return 5 headlines and 4 descriptions
- Return JSON only: {"headlines": ["...", ...], "descriptions": ["...", ...]}`,
    }],
  });
  const text = (msg.content[0] as any).text.replace(/```json\n?/, "").replace(/```/, "").trim();
  const parsed = JSON.parse(text);

  const headlines = parsed.headlines.filter((h: string) => validateAsset(h, HEADLINE_MAX));
  const descriptions = parsed.descriptions.filter((d: string) => validateAsset(d, DESCRIPTION_MAX));

  if (headlines.length < 3 || descriptions.length < 2) {
    throw new Error("Model returned too many over-limit assets — rerun or revise manually.");
  }

  return { headlines, descriptions };
}

每个标题和说明在生成之后、在触碰 API 之前,都会在代码里按限制进行校验。模型的重写只是草稿。验证器才是关卡。

我日常如何使用它

code
> 检查我最近 7 天的谷歌广告

Claude 执行 GAQL 查询,标记出点击率低于 3% 或超过我设定的单次转化成本目标的任何广告,并在重写前征求确认。我确认后,它生成新的标题和说明,通过字符验证器筛选,剔除不合格的,然后通过谷歌广告 API 的 mutate 操作创建一个状态为 PAUSED 的新广告。我在谷歌广告里直接对比新旧文案,启用那些我真的会投放的版本,其余的保持暂停。

和 Facebook 版本一样的上限:大约 20 分钟,而不是在界面里耗掉一整个下午,因为那些机械性的部分——提取数据、在硬性约束内撰写、准备好变更——正是模型应该代替我去做的事。

验证器无法捕捉的东西

字符计数是机械性的。相关性不是。一个标题可能只有 28 个字符,通过了每一项检查,但对它所匹配的搜索词来说仍然是错误的信息。这个技能无法判断一个表现不佳的广告组是因为报价不好、与落地页不匹配,还是文案本身确实弱——它只能告诉我,这条文案相对于周围的广告是弱的。如果一个广告系列里所有广告的点击率都低,那是定位或落地页的问题,给技能更多针对错配搜索意图的重写循环,只会产生更顺从、但同样被忽略的文案。

在批准一批重写之前,我会像在 Facebook 那边一样,自己读一遍 GAQL 的输出。技能省去了我打字和数字符的工作。它没有省去判断究竟哪里出了问题的那部分。

我用来做这件事的工具

Claude 是两个广告技能——Facebook 的那个和这个——共同运行的基础,它们共享同一种重写-验证的模式,只是针对不同的 API 和更紧的字符预算。

谷歌广告 API(npm 包 google-ads-api)——包装原始 gRPC/REST API 的 TypeScript 库。它处理 OAuth 刷新和 GAQL 查询的构建,这样你就不用手动拼装 protobuf。

常见问题

我需要基本访问权限,还是测试账户访问就够了?

测试账户访问只对谷歌广告的测试账户有效——对开发有用,对有真实支出的真实账户毫无用处。在这个技能能够触碰真实广告系列之前,你需要基本访问权限(在你的管理账户的 API 中心申请)。审批是人工审核,不是即时的,所以要在需要之前申请,而不是在想上线的那一周才申请。

为什么是 5 个标题和 4 条说明,而不是最多的 15 个和 4 个?

谷歌允许最多 15 个标题,但 Ad Strength 和实际测试都表明,超过 8 到 10 个真正不同的角度之后,收益会递减——大多数账户最后都会用一堆几乎重复的标题来凑数。我把模型的输出限制在每批重写 5 个新标题,这样我审核的是真正不同的选项,而不是在筛选同一句话的九种变体。

如果模型的重写每次都没通过字符检查,会怎样?

验证器会抛出错误,而不是悄悄截断或提交一个超限的素材。实际操作中,这意味着我会重新运行一次提示词,如果两次都失败,我就自己手写那条特定的标题。这种情况在说明文字上更常见——90 个字符用来容纳一句完整的话,比看起来要紧得多。

相关阅读: 如果不想为每个平台都搭一个专用技能,而是想在一个 Claude 连接器里对 GA4、Search Console、Bing、Ahrefs 和 Core Web Vitals 做只读报表,我用的是 SiteWide MCP——同样是把 Claude 接入真实营销数据的思路,只是覆盖面比单一广告平台更广。

运营者的结论

如果你在手动写自适应搜索广告的各种变体、自己数字符,那正是模型应该接管的那种机械、基于规则的任务。真正需要判断力的部分——这是文案问题还是定位问题——仍然是你的事。把技能搭建起来,让它针对谷歌的限制去撰写和验证,诊断留给自己。

广告
继续阅读

相关文章

继续阅读

将AI实战手册发送到您的邮箱

每周三。28,400+ 读者。纯干货。

↵ ↵ 查看全部结果 esc esc 关闭