Velumi
Guía · pauta · con el código · actualizada 2026-09-19

Alertas de anomalías en Google Ads con Scripts y un modelo: el código completo

Un script de Google Ads que cada lunes compara la semana contra las cuatro anteriores, campaña por campaña, marca lo que se salió de rango y le manda a la persona de cuentas un resumen redactado por un modelo que no puede inventar un número. Con el código completo, cómo instalarlo y lo que no hace.

Respuesta corta. Las alertas de pauta no necesitan una plataforma: Google Ads Scripts corre JavaScript dentro de la cuenta, con acceso a las estadísticas por campaña, a una URL externa y al correo. El script de esta página (descargar, 403 líneas) lee costo, clics, impresiones y conversiones de dos ventanas, calcula CPA y CTR en código, marca como anomalía toda variación mayor que el umbral cuando la base tiene datos suficientes, arma una tabla y, si hay clave, le pide a un modelo que la redacte. Antes de enviar, el código verifica que cada cifra del texto exista en la tabla. El email llega a la agencia, nunca al cliente, y nada cambia en la cuenta.

Qué hace, en cuatro pasos

  1. Ventanas. Actual: los últimos 7 días hasta ayer. Base: los 28 días anteriores, promediados a la escala de la ventana actual. Las fechas se arman en la zona horaria de la cuenta (Google Ads Scripts · Dates and times, 19-09-2026).
  2. Lectura. AdsApp.campaigns(), filtrado a campañas habilitadas, y por cada una dos llamadas a getStatsFor(desde, hasta) con fechas YYYYMMDD (Google Ads Scripts · AdsApp.Campaign, 19-09-2026). De cada Stats se toman getCost(), getClicks(), getImpressions() y getConversions(); CPA y CTR se recalculan en código (Google Ads Scripts · AdsApp.Stats, 19-09-2026).
  3. Anomalías. Variación relativa contra la base por métrica; anomalía si supera CONFIG.umbral (30 % por defecto) y la base cumple el mínimo de datos. Dos casos especiales con gravedad alta: gastaba y dejó de gastar; convertía y dejó de convertir.
  4. Resumen y cola. Tabla de texto con totales, anomalías y una fila por campaña. Si hay clave, un POST a /v1/messages con la tabla y las reglas; el texto pasa por la compuerta de cifras. MailApp.sendEmail a la persona de cuentas con una primera línea que dice que es una cola de aprobación; opcionalmente, una fila PENDIENTE en una hoja de cálculo.

Qué no hace

  • No cambia pujas, presupuestos, estados de campaña, anuncios ni palabras clave. Las únicas llamadas a AdsApp son de lectura y el test del repositorio falla si aparece otra.
  • No escribe al cliente. El único destinatario es la persona de cuentas configurada; el email dice en la primera línea que es una cola de aprobación.
  • No decide qué hacer con una anomalía. Lista el hecho, la variación y la gravedad; la hipótesis, si el modelo la escribe, va marcada como hipótesis.
  • No toma cifras del modelo. Toda cifra nace en getStatsFor y pasa por la tabla; el modelo solo redacta y el código verifica que no haya agregado ninguna.
  • No mira dentro del día en curso: la ventana termina ayer, porque hoy está incompleto y compararlo distorsionaría el promedio.
  • No cubre video, shopping ni Performance Max: el selector estándar las excluye y no verificamos el contrato de sus selectores propios.
Por qué no automatizamos la reacción. Un script que pausa campañas o baja presupuestos cuando detecta una anomalía convierte un falso positivo (un feriado, una demora de atribución, una etiqueta rota) en dinero del cliente perdido sin que nadie lo haya decidido. Es la falla «publica solo» de las fallas al automatizar una agencia, aplicada a pauta. El script termina en una persona con nombre, igual que el reporte mensual.

La regla de anomalía, métrica por métrica

Todo esto es criterio operativo nuestro, no un estándar de la industria: los valores por defecto están en CONFIG y la persona de cuentas los ajusta por cliente.

MétricaCuándo marcaGravedadPor qué
CostoLa ventana actual varía más que el umbral contra la base semanal; si la base gastaba y la ventana actual gastó cero, es «sin gasto».Media; «sin gasto» es altaUn costo que cae a cero suele ser un presupuesto agotado, una tarjeta rechazada o una campaña pausada por error: es lo primero que hay que mirar el lunes.
ConversionesVarían más que el umbral y la base tiene al menos el mínimo de conversiones; si la base convertía y la ventana actual tiene cero, es «sin conversiones».Alta si caen o son cero; media si subenCon pocas conversiones en la base, la variación semanal es ruido; por eso el mínimo aparte.
CPAVaría más que el umbral, con el mismo mínimo de conversiones que la fila anterior.Alta si sube; media si bajaEs la métrica por la que el cliente juzga la pauta. Se recalcula en código como costo entre conversiones, en las dos ventanas.
CTRVaría más que el umbral. Se recalcula como clics entre impresiones.MediaUn CTR que se desploma anticipa un problema de anuncios o de segmentación antes de que se note en el costo.

Mínimo de datos. La base de veintiocho días tiene que superar el mínimo de clics y de costo configurado; para juzgar conversiones y CPA, además, el mínimo de conversiones. Los valores por defecto del script son un punto de partida para que la persona de cuentas los ajuste por cliente; no salen de ningún estudio. En el script: CONFIG.minimo = 100 clics, 50 de costo en la moneda de la cuenta y 5 conversiones en la base de 28 días. Sin el mínimo, la campaña aparece en la tabla como «sin datos suficientes» y sus variaciones no se interpretan: una campaña nueva que pasó de dos clics a cuarenta no es una anomalía, es una campaña nueva.

El script

Un solo archivo, sin dependencias. Se pega tal cual en el editor de scripts de Google Ads y se edita únicamente el bloque CONFIG. Las funciones de la sección 4 (anomalías) y 6 (compuerta) son puras: nuestro repositorio las ejecuta sobre un caso sintético en cada build, así que lo que leés acá es lo que se probó. Descargar el archivo.

/**
 * Alertas de anomalías y resumen semanal de pauta
 * Google Ads Scripts + modelo de lenguaje, con compuerta de cifras y cola de aprobación.
 *
 * Publicado en https://velumi.io/guias/pauta-alertas-anomalias/ · versión 1.0 · 2026-09-19
 * Uso libre, sin garantía. Leé la guía antes de programarlo.
 *
 * QUÉ HACE
 *  1. Recorre las campañas de la cuenta y compara los últimos 7 días (hasta ayer) contra los 28 días
 *     previos, normalizados a 7, en costo, conversiones, CPA y CTR.
 *  2. Marca anomalías con un umbral relativo configurable y una regla de mínimo de datos: si la base
 *     no tiene datos suficientes, la campaña queda como «sin datos», no como anomalía.
 *  3. Arma la tabla del resumen semanal y, si hay clave, le pide a un modelo que la redacte.
 *     Compuerta de cifras: todo número del texto tiene que existir en la tabla; si no, se reintenta
 *     una vez y a la segunda se envía la tabla sin narrativa.
 *  4. Envía el resultado por email a la persona de cuentas (la cola de aprobación). Nunca al cliente.
 *
 * QUÉ NO HACE
 *  - No modifica pujas, presupuestos, estados, anuncios ni palabras clave. Las únicas llamadas a
 *    AdsApp son campaigns(), currentAccount() y getExecutionInfo(): todas de lectura.
 *  - No escribe al cliente ni publica nada. El único destinatario es CONFIG.destinatario.
 */

// ---------------------------------------------------------------------------------------------
// 1. Configuración: lo único que se edita por cliente.
// ---------------------------------------------------------------------------------------------
const CONFIG = {
  cliente: 'Acme',                          // nombre que verá la persona de cuentas en el asunto
  destinatario: 'cuentas@tuagencia.com',    // quien aprueba; se admiten varios separados por coma
  umbral: 0.30,                             // variación relativa que cuenta como anomalía (0.30 = 30 %)
  minimo: {                                 // mínimo de datos en la base de 28 días; por debajo, «sin datos»
    clics: 100,
    costo: 50,                              // en la moneda de la cuenta
    conversiones: 5,                        // requerido solo para juzgar conversiones y CPA
  },
  ventanas: { actual: 7, base: 28 },        // días; la base se normaliza a la ventana actual
  incluirPausadas: false,                   // true: también compara campañas en pausa
  modelo: {
    clave: '',                              // clave de API de Anthropic; vacía = tabla sin narrativa
    id: 'claude-opus-5',
    maxTokens: 1500,
    reintentos: 1,                          // reintentos si la compuerta de cifras rechaza el texto
  },
  hojaCola: '',                             // URL de una hoja de cálculo para la cola; vacía = solo email
  hojaColaPestana: 'cola',
};

// ---------------------------------------------------------------------------------------------
// 2. Punto de entrada
// ---------------------------------------------------------------------------------------------
function main() {
  const cuenta = AdsApp.currentAccount();
  const zona = cuenta.getTimeZone();
  const moneda = cuenta.getCurrencyCode();
  const rango = rangos(new Date(), zona, CONFIG.ventanas);
  const filas = leerCampanas(rango);
  const resumen = armarResumen(filas, rango, moneda, CONFIG);
  const tabla = tablaTexto(resumen);

  let compuerta = { texto: null, motivo: 'sin clave: se envía la tabla sin narrativa', intentos: 0, uso: null };
  if (CONFIG.modelo.clave) compuerta = redactarConCompuerta(tabla, CONFIG.modelo);

  entregar(resumen, tabla, compuerta);
}

// ---------------------------------------------------------------------------------------------
// 3. Lectura de Google Ads (solo lectura)
// ---------------------------------------------------------------------------------------------

// Ventanas en YYYYMMDD para getStatsFor y en YYYY-MM-DD para el texto. Termina ayer: hoy está incompleto.
function rangos(hoy, zona, ventanas) {
  const DIA = 24 * 60 * 60 * 1000;
  const f = function (d, formato) { return Utilities.formatDate(d, zona, formato); };
  const dia = function (n) { return new Date(hoy.getTime() - n * DIA); };
  const actualDesde = dia(ventanas.actual);
  const actualHasta = dia(1);
  const baseDesde = dia(ventanas.actual + ventanas.base);
  const baseHasta = dia(ventanas.actual + 1);
  return {
    actual: { desde: f(actualDesde, 'yyyyMMdd'), hasta: f(actualHasta, 'yyyyMMdd'), desdeIso: f(actualDesde, 'yyyy-MM-dd'), hastaIso: f(actualHasta, 'yyyy-MM-dd') },
    base: { desde: f(baseDesde, 'yyyyMMdd'), hasta: f(baseHasta, 'yyyyMMdd'), desdeIso: f(baseDesde, 'yyyy-MM-dd'), hastaIso: f(baseHasta, 'yyyy-MM-dd') },
    dias: ventanas,
  };
}

function leerStats(s) {
  return { costo: s.getCost(), clics: s.getClicks(), impresiones: s.getImpressions(), conversiones: s.getConversions() };
}

function leerCampanas(rango) {
  let selector = AdsApp.campaigns();
  if (!CONFIG.incluirPausadas) selector = selector.withCondition('campaign.status = ENABLED');
  const iterador = selector.get();
  const filas = [];
  while (iterador.hasNext()) {
    const c = iterador.next();
    filas.push({
      id: String(c.getId()),
      nombre: c.getName(),
      habilitada: c.isEnabled(),
      actual: leerStats(c.getStatsFor(rango.actual.desde, rango.actual.hasta)),
      base: leerStats(c.getStatsFor(rango.base.desde, rango.base.hasta)),
    });
  }
  return filas;
}

// ---------------------------------------------------------------------------------------------
// 4. Lógica de anomalías (funciones puras: se prueban sin Google Ads)
// ---------------------------------------------------------------------------------------------

function derivar(p) {
  return {
    costo: p.costo,
    clics: p.clics,
    impresiones: p.impresiones,
    conversiones: p.conversiones,
    cpa: p.conversiones > 0 ? p.costo / p.conversiones : null,
    ctr: p.impresiones > 0 ? p.clics / p.impresiones : null,
  };
}

// La base de 28 días se lleva a la escala de la ventana actual (7 días): se comparan semanas con semanas.
function normalizarBase(base, ventanas) {
  const factor = ventanas.actual / ventanas.base;
  return { costo: base.costo * factor, clics: base.clics * factor, impresiones: base.impresiones * factor, conversiones: base.conversiones * factor };
}

function variacion(actual, base) {
  if (actual === null || base === null || base === 0) return null;
  return (actual - base) / base;
}

function evaluarCampana(fila, config) {
  const faltan = [];
  if (fila.base.clics < config.minimo.clics) faltan.push('clics');
  if (fila.base.costo < config.minimo.costo) faltan.push('costo');
  const suficiente = faltan.length === 0;
  const conversionesJuzgables = suficiente && fila.base.conversiones >= config.minimo.conversiones;

  const actual = derivar(fila.actual);
  const base = derivar(normalizarBase(fila.base, config.ventanas));
  const cambio = {
    costo: variacion(actual.costo, base.costo),
    conversiones: variacion(actual.conversiones, base.conversiones),
    cpa: variacion(actual.cpa, base.cpa),
    ctr: variacion(actual.ctr, base.ctr),
  };

  const anomalias = [];
  const marcar = function (metrica, tipo, valor, gravedad) { anomalias.push({ metrica: metrica, tipo: tipo, variacion: valor, gravedad: gravedad }); };
  const supera = function (v) { return v !== null && Math.abs(v) >= config.umbral; };

  if (suficiente) {
    if (base.costo > 0 && actual.costo === 0) marcar('costo', 'sin_gasto', -1, 'alta');
    else if (supera(cambio.costo)) marcar('costo', cambio.costo > 0 ? 'sube' : 'baja', cambio.costo, 'media');

    if (conversionesJuzgables) {
      if (actual.conversiones === 0) marcar('conversiones', 'sin_conversiones', -1, 'alta');
      else {
        if (supera(cambio.conversiones)) marcar('conversiones', cambio.conversiones > 0 ? 'sube' : 'baja', cambio.conversiones, cambio.conversiones < 0 ? 'alta' : 'media');
        if (supera(cambio.cpa)) marcar('cpa', cambio.cpa > 0 ? 'sube' : 'baja', cambio.cpa, cambio.cpa > 0 ? 'alta' : 'media');
      }
    }

    if (supera(cambio.ctr)) marcar('ctr', cambio.ctr > 0 ? 'sube' : 'baja', cambio.ctr, 'media');
  }

  return {
    id: fila.id,
    nombre: fila.nombre,
    habilitada: fila.habilitada,
    actual: actual,
    base: base,
    cambio: cambio,
    anomalias: anomalias,
    suficiente: suficiente,
    conversionesJuzgables: conversionesJuzgables,
    faltan: faltan,
  };
}

function armarResumen(filas, rango, moneda, config) {
  const campanas = filas.map(function (f) { return evaluarCampana(f, config); });
  const peso = { alta: 2, media: 1 };
  const gravedadMax = function (c) { return c.anomalias.reduce(function (m, a) { return Math.max(m, peso[a.gravedad]); }, 0); };
  campanas.sort(function (a, b) { return gravedadMax(b) - gravedadMax(a) || b.actual.costo - a.actual.costo; });

  const sumar = function (campo, periodo) { return campanas.reduce(function (t, c) { return t + c[periodo][campo]; }, 0); };
  const totales = {
    actual: derivar({ costo: sumar('costo', 'actual'), clics: sumar('clics', 'actual'), impresiones: sumar('impresiones', 'actual'), conversiones: sumar('conversiones', 'actual') }),
    base: derivar({ costo: sumar('costo', 'base'), clics: sumar('clics', 'base'), impresiones: sumar('impresiones', 'base'), conversiones: sumar('conversiones', 'base') }),
  };
  totales.cambio = {
    costo: variacion(totales.actual.costo, totales.base.costo),
    conversiones: variacion(totales.actual.conversiones, totales.base.conversiones),
    cpa: variacion(totales.actual.cpa, totales.base.cpa),
    ctr: variacion(totales.actual.ctr, totales.base.ctr),
  };
  const nAnomalias = campanas.reduce(function (n, c) { return n + c.anomalias.length; }, 0);
  const nAltas = campanas.reduce(function (n, c) { return n + c.anomalias.filter(function (a) { return a.gravedad === 'alta'; }).length; }, 0);
  return {
    cliente: config.cliente,
    moneda: moneda,
    rango: rango,
    umbral: config.umbral,
    minimo: config.minimo,
    campanas: campanas,
    totales: totales,
    conteo: { campanas: campanas.length, conAnomalias: campanas.filter(function (c) { return c.anomalias.length > 0; }).length, anomalias: nAnomalias, altas: nAltas, sinDatos: campanas.filter(function (c) { return !c.suficiente; }).length },
  };
}

// ---------------------------------------------------------------------------------------------
// 5. La tabla: única fuente de cifras para el modelo y para el email
// ---------------------------------------------------------------------------------------------

function num(v, decimales) {
  if (v === null || v === undefined || isNaN(v)) return 'sin dato';
  return Number(v).toFixed(decimales);
}
function pct(v, decimales) {
  if (v === null || v === undefined || isNaN(v)) return 'sin dato';
  return (v * 100).toFixed(decimales) + '%';
}
function pctSigno(v) {
  if (v === null || v === undefined || isNaN(v)) return 'sin dato';
  const p = (v * 100).toFixed(0);
  return (v > 0 ? '+' : '') + p + '%';
}

function tablaTexto(r) {
  const L = [];
  L.push('RESUMEN SEMANAL DE PAUTA · ' + r.cliente);
  L.push('Ventana actual: ' + r.rango.actual.desdeIso + ' a ' + r.rango.actual.hastaIso + ' (' + r.rango.dias.actual + ' dias)');
  L.push('Base: ' + r.rango.base.desdeIso + ' a ' + r.rango.base.hastaIso + ' (' + r.rango.dias.base + ' dias, promediados a ' + r.rango.dias.actual + ')');
  L.push('Moneda: ' + r.moneda + ' · Umbral de anomalia: ' + pct(r.umbral, 0) + ' · Minimo de datos en la base: ' + r.minimo.clics + ' clics, ' + num(r.minimo.costo, 0) + ' de costo, ' + r.minimo.conversiones + ' conversiones');
  L.push('Campanas analizadas: ' + r.conteo.campanas + ' · Con anomalias: ' + r.conteo.conAnomalias + ' · Anomalias: ' + r.conteo.anomalias + ' (altas: ' + r.conteo.altas + ') · Sin datos suficientes: ' + r.conteo.sinDatos);
  L.push('');
  L.push('TOTALES (actual | base semanal | variacion)');
  L.push('Costo: ' + num(r.totales.actual.costo, 2) + ' | ' + num(r.totales.base.costo, 2) + ' | ' + pctSigno(r.totales.cambio.costo));
  L.push('Conversiones: ' + num(r.totales.actual.conversiones, 1) + ' | ' + num(r.totales.base.conversiones, 1) + ' | ' + pctSigno(r.totales.cambio.conversiones));
  L.push('CPA: ' + num(r.totales.actual.cpa, 2) + ' | ' + num(r.totales.base.cpa, 2) + ' | ' + pctSigno(r.totales.cambio.cpa));
  L.push('CTR: ' + pct(r.totales.actual.ctr, 2) + ' | ' + pct(r.totales.base.ctr, 2) + ' | ' + pctSigno(r.totales.cambio.ctr));
  L.push('');
  L.push('ANOMALIAS (campana · metrica · tipo · variacion · gravedad)');
  let hay = false;
  r.campanas.forEach(function (c) {
    c.anomalias.forEach(function (a) {
      hay = true;
      L.push('- ' + c.nombre + ' · ' + a.metrica + ' · ' + a.tipo + ' · ' + pctSigno(a.variacion) + ' · ' + a.gravedad);
    });
  });
  if (!hay) L.push('- ninguna');
  L.push('');
  L.push('POR CAMPANA (costo actual | base; conversiones actual | base; CPA actual | base; CTR actual | base)');
  r.campanas.forEach(function (c) {
    const estado = c.suficiente ? '' : ' [SIN DATOS SUFICIENTES: ' + c.faltan.join(', ') + ']';
    L.push('- ' + c.nombre + estado + ': costo ' + num(c.actual.costo, 2) + ' | ' + num(c.base.costo, 2) +
      '; conv ' + num(c.actual.conversiones, 1) + ' | ' + num(c.base.conversiones, 1) +
      '; CPA ' + num(c.actual.cpa, 2) + ' | ' + num(c.base.cpa, 2) +
      '; CTR ' + pct(c.actual.ctr, 2) + ' | ' + pct(c.base.ctr, 2));
  });
  return L.join('\n');
}

// ---------------------------------------------------------------------------------------------
// 6. Compuerta de cifras: el modelo redacta, el código verifica
// ---------------------------------------------------------------------------------------------

// Extrae los números de un texto. Ignora fechas (YYYY-MM-DD, DD/MM/YYYY) y admite coma decimal.
function numerosDeTexto(texto) {
  const sinFechas = String(texto).replace(/\d{4}-\d{2}-\d{2}/g, ' ').replace(/\d{2}\/\d{2}\/\d{4}/g, ' ');
  const crudos = sinFechas.match(/\d+(?:[.,]\d+)?/g) || [];
  return crudos.map(function (c) { return { crudo: c, valor: parseFloat(c.replace(',', '.')) }; });
}

// Tolerancia (criterio propio, la misma de la plantilla de reportes): absoluta 0.05 o relativa 0.1 %;
// para valores menores que uno rige solo la relativa.
function coincide(valor, permitido) {
  const a = Math.abs(valor);
  const b = Math.abs(permitido);
  const limite = b < 1 ? 0.001 * b : Math.max(0.05, 0.001 * b);
  return Math.abs(a - b) <= limite;
}

function verificarCifras(texto, permitidos) {
  const huerfanas = [];
  let verificadas = 0;
  numerosDeTexto(texto).forEach(function (n) {
    const ok = permitidos.some(function (p) { return coincide(n.valor, p.valor); });
    if (ok) verificadas++; else huerfanas.push(n.crudo);
  });
  const muletillas = String(texto).match(/\b(aproximadamente|alrededor de|cerca de|estimamos)\b\s+\d/gi) || [];
  return { ok: huerfanas.length === 0 && muletillas.length === 0, verificadas: verificadas, huerfanas: huerfanas, muletillas: muletillas };
}

// El prompt no contiene ningún dígito: todas las cifras entran por la tabla.
const INSTRUCCIONES = [
  'Sos analista de pauta digital en una agencia de marketing de Latinoamerica. Redactas para la persona de cuentas, no para el cliente.',
  'Recibis una tabla con cifras ya calculadas y verificadas. Tu unico trabajo es redactarla en espanol claro.',
  'Reglas que no se negocian:',
  '- Usa solo numeros que aparezcan en la tabla, copiados exactamente como estan: mismos decimales, punto como separador decimal, sin separador de miles.',
  '- No calcules, no redondees, no sumes ni estimes ninguna cifra nueva. No uses las palabras aproximadamente, alrededor de, cerca de ni estimamos delante de un numero.',
  '- No inventes causas. Si proponés una hipotesis sobre por que paso algo, marcala con la palabra hipotesis y no la presentes como hecho.',
  '- No recomiendes cambios de puja ni de presupuesto. Si algo merece revision, decilo como pregunta para la persona de cuentas.',
  '- Si una campana figura como SIN DATOS SUFICIENTES, decilo asi; no interpretes sus variaciones.',
  'Estructura: un parrafo de resumen con los totales; una lista con cada anomalia (campana, metrica, variacion, gravedad) y una pregunta o hipotesis por anomalia; una frase final sobre las campanas sin datos, si las hay. Sin saludo ni despedida.',
].join('\n');

function construirPeticion(tabla, huerfanas, cfg) {
  let pedido = 'Tabla semanal:\n\n' + tabla + '\n\nRedacta el resumen siguiendo las reglas.';
  if (huerfanas && huerfanas.length) {
    pedido += '\n\nEl intento anterior fue rechazado porque estas cifras no estan en la tabla: ' + huerfanas.join(', ') + '. Volve a redactar usando solo cifras de la tabla.';
  }
  return {
    model: cfg.id,
    max_tokens: cfg.maxTokens,
    system: INSTRUCCIONES,
    messages: [{ role: 'user', content: pedido }],
  };
}

// POST https://api.anthropic.com/v1/messages (headers x-api-key, anthropic-version, content-type).
function llamarModelo(peticion, cfg) {
  const respuesta = UrlFetchApp.fetch('https://api.anthropic.com/v1/messages', {
    method: 'post',
    contentType: 'application/json',
    headers: { 'x-api-key': cfg.clave, 'anthropic-version': '2023-06-01' },
    payload: JSON.stringify(peticion),
    muteHttpExceptions: true,
  });
  const codigo = respuesta.getResponseCode();
  if (codigo !== 200) {
    Logger.log('Modelo: HTTP ' + codigo + ' · ' + respuesta.getContentText().slice(0, 500));
    return null;
  }
  const json = JSON.parse(respuesta.getContentText());
  if (json.stop_reason === 'refusal') {
    Logger.log('Modelo: la respuesta fue rechazada por el proveedor (stop_reason refusal).');
    return null;
  }
  const texto = (json.content || []).filter(function (b) { return b.type === 'text'; }).map(function (b) { return b.text; }).join('\n').trim();
  return { texto: texto, uso: json.usage || null, stop: json.stop_reason };
}

function redactarConCompuerta(tabla, cfg) {
  const permitidos = numerosDeTexto(tabla);
  let huerfanas = [];
  let uso = null;
  for (let intento = 1; intento <= 1 + cfg.reintentos; intento++) {
    const r = llamarModelo(construirPeticion(tabla, huerfanas, cfg), cfg);
    if (!r) return { texto: null, motivo: 'sin respuesta valida del modelo: se envia la tabla sin narrativa', intentos: intento, uso: uso };
    uso = r.uso;
    if (r.stop === 'max_tokens') Logger.log('Modelo: la respuesta se corto por max_tokens; se verifica igual.');
    const v = verificarCifras(r.texto, permitidos);
    if (v.ok) return { texto: r.texto, motivo: 'ok: ' + v.verificadas + ' cifras verificadas contra la tabla', intentos: intento, uso: uso };
    huerfanas = v.huerfanas.concat(v.muletillas);
    Logger.log('Compuerta: intento ' + intento + ' rechazado. Huerfanas: ' + huerfanas.join(', '));
  }
  return { texto: null, motivo: 'la compuerta rechazo la narrativa (' + huerfanas.join(', ') + '): se envia la tabla sin narrativa', intentos: 1 + cfg.reintentos, uso: uso };
}

// ---------------------------------------------------------------------------------------------
// 7. Entrega: email a la persona de cuentas (cola de aprobación) y, opcional, fila en una hoja
// ---------------------------------------------------------------------------------------------

function escaparHtml(s) {
  return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

function entregar(resumen, tabla, compuerta) {
  const n = resumen.conteo.anomalias;
  const asunto = '[Pauta · ' + resumen.cliente + '] Semana ' + resumen.rango.actual.desdeIso + ' a ' + resumen.rango.actual.hastaIso +
    (n > 0 ? ' · ' + n + ' anomalia' + (n === 1 ? '' : 's') + (resumen.conteo.altas > 0 ? ' (' + resumen.conteo.altas + ' alta' + (resumen.conteo.altas === 1 ? '' : 's') + ')' : '') : ' · sin anomalias');

  const aviso = 'COLA DE APROBACION. Este resumen NO fue enviado al cliente y el script no cambio nada en la cuenta. ' +
    'Revisalo, editalo y enviaselo vos con tu nombre; si una anomalia requiere accion, la decidis en Google Ads, no desde el script.';
  const pie = 'Narrativa: ' + compuerta.motivo + ' · intentos: ' + compuerta.intentos +
    (compuerta.uso ? ' · tokens entrada ' + compuerta.uso.input_tokens + ', salida ' + compuerta.uso.output_tokens : '') +
    ' · modelo: ' + (CONFIG.modelo.clave ? CONFIG.modelo.id : 'no usado');

  const cuerpo = [aviso, '', compuerta.texto || '(Sin narrativa: leer la tabla.)', '', tabla, '', pie].join('\n');
  const html = '<p><strong>' + escaparHtml(aviso) + '</strong></p>' +
    (compuerta.texto ? '<div style="white-space:pre-wrap;font-family:sans-serif">' + escaparHtml(compuerta.texto) + '</div>' : '<p><em>Sin narrativa: leer la tabla.</em></p>') +
    '<pre style="font-size:12px;line-height:1.4">' + escaparHtml(tabla) + '</pre>' +
    '<p style="color:#666;font-size:12px">' + escaparHtml(pie) + '</p>';

  if (AdsApp.getExecutionInfo().isPreview()) {
    Logger.log('VISTA PREVIA: no se envia email ni se escribe la hoja. Asunto: ' + asunto + '\n\n' + cuerpo);
    return;
  }

  MailApp.sendEmail(CONFIG.destinatario, asunto, cuerpo, { htmlBody: html, name: 'Alertas de pauta · ' + resumen.cliente });
  Logger.log('Email enviado a ' + CONFIG.destinatario + ' · ' + asunto);

  if (CONFIG.hojaCola) {
    const hoja = SpreadsheetApp.openByUrl(CONFIG.hojaCola).getSheetByName(CONFIG.hojaColaPestana);
    if (!hoja) { Logger.log('Hoja de cola: no existe la pestana ' + CONFIG.hojaColaPestana); return; }
    hoja.appendRow([new Date(), resumen.cliente, resumen.rango.actual.desdeIso + ' a ' + resumen.rango.actual.hastaIso, n, resumen.conteo.altas, 'PENDIENTE', compuerta.motivo, compuerta.texto || '']);
  }
}

Versión 1.0 · 2026-09-19. Métodos de la plataforma verificados contra la documentación oficial el 19-09-2026: la lista completa está al pie. Si un método cambia, se corrige acá y en el archivo descargable.

La compuerta de cifras, con un ejemplo

El modelo recibe la tabla y unas instrucciones que no contienen un solo dígito; redacta; el código extrae cada número del texto (ignorando fechas, aceptando coma o punto decimal) y lo busca en la tabla con la misma tolerancia que la plantilla de reporte mensual. Si falta alguno, o si aparece «aproximadamente» delante de una cifra, el texto se rechaza. Esta es la tabla que produce el script con cuatro campañas inventadas:

RESUMEN SEMANAL DE PAUTA · Acme (ejemplo)
Ventana actual: 2026-09-07 a 2026-09-13 (7 dias)
Base: 2026-08-10 a 2026-09-06 (28 dias, promediados a 7)
Moneda: MXN · Umbral de anomalia: 30% · Minimo de datos en la base: 100 clics, 50 de costo, 5 conversiones
Campanas analizadas: 4 · Con anomalias: 2 · Anomalias: 5 (altas: 4) · Sin datos suficientes: 1

TOTALES (actual | base semanal | variacion)
Costo: 1460.00 | 1267.50 | +15%
Conversiones: 41.0 | 58.0 | -29%
CPA: 35.61 | 21.85 | +63%
CTR: 3.32% | 2.07% | +61%

ANOMALIAS (campana · metrica · tipo · variacion · gravedad)
- Search · Genéricas · costo · sube · +40% · media
- Search · Genéricas · conversiones · baja · -59% · alta
- Search · Genéricas · cpa · sube · +242% · alta
- Display · Remarketing · costo · sin_gasto · -100% · alta
- Display · Remarketing · conversiones · sin_conversiones · -100% · alta

POR CAMPANA (costo actual | base; conversiones actual | base; CPA actual | base; CTR actual | base)
- Search · Genéricas: costo 980.00 | 700.00; conv 9.0 | 22.0; CPA 108.89 | 31.82; CTR 2.33% | 2.96%
- Display · Remarketing: costo 0.00 | 160.00; conv 0.0 | 6.0; CPA sin dato | 26.67; CTR sin dato | 0.90%
- Search · Marca: costo 420.00 | 400.00; conv 31.0 | 30.0; CPA 13.55 | 13.33; CTR 6.22% | 5.85%
- Search · Prueba nueva [SIN DATOS SUFICIENTES: clics, costo]: costo 60.00 | 7.50; conv 1.0 | 0.0; CPA 60.00 | sin dato; CTR 4.44% | 4.40%
Pasa la compuerta (6 cifras verificadas, ninguna huérfana): «La inversión total de la semana fue de 1460.00 MXN contra 1267.50 de base semanal (+15%). En Search · Genéricas el CPA pasó de 31.82 a 108.89 (+242%), gravedad alta. Hipótesis: cambio en la medición o en la página de destino.»
No pasa (huérfanas: 1500, 12, 15.05; muletilla: «aproximadamente 1…»): «La inversión total fue de aproximadamente 1500 MXN, un 12% menos que el mes pasado; en Search · Marca el CPA subió a 15.05.». Se reintenta una vez con la lista de cifras rechazadas en el pedido; a la segunda, el email sale con la tabla sola y este motivo al pie.

Fijate en el total de CTR del ejemplo: sube porque la campaña de display, que aportaba la mayoría de las impresiones, dejó de gastar. Es correcto y es engañoso a la vez; por eso el resumen lista las anomalías por campaña antes que los totales, y por eso la persona de cuentas lee la tabla, no solo el párrafo.

Cómo instalarlo

  1. Abrir el editor. En Google Ads: menú Herramientas → Acciones en bloque → Scripts (o escribir «Scripts» en la barra de búsqueda). Botón + → Nuevo script. Ponerle nombre: «Alertas semanales · Acme». (Google Ads Scripts · Get started, 19-09-2026)
  2. Pegar el código. Reemplazar todo el contenido del editor con el script de esta página. Editar solo el bloque CONFIG: nombre del cliente, email de la persona de cuentas, umbral, mínimos y, si se quiere narrativa, la clave del modelo. (Google Ads Scripts · Get started, 19-09-2026)
  3. Autorizar. Al primer Vista previa o Ejecutar, Google pide autorización: una vez por script. Hacerlo con un usuario de la agencia con acceso estable a la cuenta; si esa persona pierde el acceso, el script deja de correr. (Google Ads Scripts · Authorization, 19-09-2026)
  4. Vista previa. Correr en Vista previa: el script detecta la vista previa con isPreview() y escribe en el log el asunto y el cuerpo del email en lugar de enviarlo. Revisar que las campañas, las ventanas y la moneda sean las esperadas. (Google Ads Scripts · AdsApp, 19-09-2026)
  5. Ejecutar una vez. Clic en Ejecutar. Tiene que llegar el email a la persona de cuentas con la tabla y, si hay clave, la narrativa con la nota de la compuerta al pie. Si la compuerta rechazó el texto, llega la tabla sola con el motivo. (Google Ads Scripts · Get started, 19-09-2026)
  6. Programar. En la columna Frecuencia de la página Scripts: lápiz → Semanal → lunes, a primera hora de la zona horaria de la cuenta → Guardar. Así «ayer» es domingo y la ventana actual es la semana calendario completa. (Ayuda de Google Ads · Acerca de los scripts (programación), 19-09-2026)

Sobre la clave del modelo. Queda en el código del script, visible para cualquier usuario con acceso a Scripts en esa cuenta. Usá una clave creada solo para este uso, con el nombre del cliente, y rotala si alguien deja de tener acceso. Si preferís no pegarla en la cuenta del cliente, corré el script desde la cuenta de administrador de la agencia con la variante de abajo: la clave vive en un solo lugar.

Varias cuentas desde el administrador (MCC)

La cuenta de administrador ejecuta la misma función en cada cuenta cliente con executeInParallel, cada una con su propia cuota de tiempo, y recibe los resultados en un callback (Google Ads Scripts · AdsManagerApp.ManagedAccountSelector.executeInParallel, 19-09-2026). Se reemplaza main() por esto y el resto del script se pega igual:

// Cuenta de administrador: reemplazá main() por estas tres funciones y pegá el resto del script igual.
// Hasta 50 cuentas por corrida con executeInParallel (límite de la plataforma). Con callback, el
// script puede correr hasta 60 minutos en vez de 30.
const CUENTAS = {
  '123-456-7890': { cliente: 'Acme', destinatario: 'ana@tuagencia.com' },
  '234-567-8901': { cliente: 'Globex', destinatario: 'luis@tuagencia.com' },
};

function main() {
  AdsManagerApp.accounts().withIds(Object.keys(CUENTAS)).executeInParallel('procesarCuenta', 'alTerminar');
}

// Corre dentro de cada cuenta cliente, con su propia cuota de 30 minutos.
function procesarCuenta() {
  const cuenta = AdsApp.currentAccount();
  const datos = CUENTAS[cuenta.getCustomerId()];
  CONFIG.cliente = datos.cliente;
  CONFIG.destinatario = datos.destinatario;

  const rango = rangos(new Date(), cuenta.getTimeZone(), CONFIG.ventanas);
  const resumen = armarResumen(leerCampanas(rango), rango, cuenta.getCurrencyCode(), CONFIG);
  const tabla = tablaTexto(resumen);
  let compuerta = { texto: null, motivo: 'sin clave: se envía la tabla sin narrativa', intentos: 0, uso: null };
  if (CONFIG.modelo.clave) compuerta = redactarConCompuerta(tabla, CONFIG.modelo);
  entregar(resumen, tabla, compuerta);

  return JSON.stringify({ cliente: datos.cliente, anomalias: resumen.conteo.anomalias, altas: resumen.conteo.altas, narrativa: compuerta.motivo });
}

// Corre una sola vez, en la cuenta de administrador, cuando terminaron todas.
function alTerminar(resultados) {
  for (const r of resultados) {
    if (r.getError()) Logger.log(r.getCustomerId() + ' · ERROR: ' + r.getError());
    else Logger.log(r.getCustomerId() + ' · ' + r.getReturnValue());
  }
}

Límites que hay que conocer

QuéLímiteQué implica acáFuente
Tiempo de ejecución30 minutos por script (60 en administrador con executeInParallel y callback)Dos lecturas de stats por campaña más una llamada al modelo entran con margen; si una cuenta tiene miles de campañas, filtrar por etiqueta o estado.Google Ads Scripts · Limits, 19-09-2026
Cuentas por corrida (administrador)50 con executeInParallel; con más, excepción y no procesa ningunaCon más de cincuenta clientes, dos scripts con listas distintas o withLimit(50).Google Ads Scripts · AdsManagerApp.ManagedAccountSelector.executeInParallel, 19-09-2026
Llamadas de URL Fetch20.000 por día (consumidor) · 100.000 (Workspace)Este script hace una o dos por cliente por semana.Apps Script · Quotas for Google Services, 19-09-2026
Destinatarios de email100 por día (consumidor) · 1.500 (Workspace); 50 por mensajeUn email por cliente por semana. En una cuenta de consumidor, cien clientes es el techo diario.Apps Script · Quotas for Google Services, 19-09-2026
Campañas que ve el selectorAdsApp.campaigns() devuelve búsqueda y display; excluye video, shopping y Performance MaxPara Performance Max existe performanceMaxCampaigns(); no lo incluimos porque no verificamos que exponga getStatsFor con el mismo contrato.Google Ads Scripts · AdsApp, 19-09-2026
Resultados por iterador50.000 por defectoIrrelevante para campañas; importa si alguien adapta el script a palabras clave.Google Ads Scripts · Limits, 19-09-2026
Scripts autorizados por cuenta250Al pasar el límite se desautoriza uno anterior de forma temporal.Google Ads Scripts · Limits, 19-09-2026

La página de límites de Google Ads Scripts avisa que pueden cambiar sin previo aviso; por eso el script tolera errores del modelo (muteHttpExceptions) y envía la tabla igual. La tabla de cuotas de Apps Script lista un máximo de seis minutos por ejecución que no aplica a Google Ads Scripts: rige el de treinta minutos de su propia página.

Cuánto cuesta la parte del modelo

Precios por millón de tokens publicados por Anthropic, leídos el 19-09-2026 (Claude API · Pricing). La estimación usa supuestos nuestros, explícitos y editables: 3.000 tokens de entrada por resumen (una tabla de unas treinta campañas más las instrucciones), 800 de salida, 1.2 intentos promedio por la compuerta y 4.33 semanas por mes. El email trae al pie los tokens reales de cada corrida: a la segunda semana reemplazá nuestros supuestos por tus números.

ModeloEntrada (USD / millón)Salida (USD / millón)Por resumen (estimado)Por cliente por mes (estimado)
Claude Opus 5 claude-opus-5525USD 0,042USD 0,182
Claude Sonnet 5 claude-sonnet-5210USD 0,017USD 0,073
Claude Haiku 4.5 claude-haiku-4-515USD 0,008USD 0,036

El script trae claude-opus-5 por defecto porque la tarea exige seguir reglas estrictas sobre cifras y marcar hipótesis como hipótesis; si tu compuerta registra pocos rechazos durante un mes, probá un modelo más barato y medí si los rechazos suben. El costo real de esta automatización no está en los tokens: está en la hora de la persona de cuentas que lee el email y decide.

Qué hace la persona de cuentas con una alerta

  1. Abrir la campaña en Google Ads y confirmar el hecho con los mismos rangos de fechas; la tabla trae las fechas exactas.
  2. «Sin gasto»: revisar presupuesto agotado, medio de pago, estado de la campaña y aprobaciones de anuncios, en ese orden.
  3. «Sin conversiones» o CPA que sube: verificar primero la medición (etiqueta, acción de conversión, cambios en el sitio) antes de tocar la pauta.
  4. CTR que cae: comparar anuncios y términos de búsqueda de la semana; un competidor nuevo o una extensión caída son causas frecuentes.
  5. Decidir, escribir la decisión en el email de aprobación y recién entonces cambiar algo en la cuenta, a mano y con tu usuario.
  6. Si la anomalía se repite tres semanas sin causa, revisar el umbral o el mínimo de datos de ese cliente: el script está midiendo ruido.

Si querés que esto forme parte de un sistema más grande (reportes mensuales, contenido, atención), la pieza que cambia es siempre la misma: quién firma. Lo explicamos en qué es un harness de IA; la lista de guías está en guías. Y si querés saber cuántas horas de tu equipo se van hoy en revisar cuentas a mano, el diagnóstico lo estima por rol.

Fuentes citadas en esta página

  • Google Ads Scripts · Limits, leída el 19-09-2026 (la página declara última actualización 10-09-2026): Tiempo máximo de ejecución: 30 minutos por script en cuentas de anunciante; en cuentas de administrador también 30, o hasta 60 si se usa executeInParallel con función de callback. Un iterador devuelve por defecto hasta 50.000 resultados; un selector admite hasta 10.000 IDs; el log se corta a 100 KB. executeInParallel procesa hasta 50 cuentas por corrida. Cada cuenta tiene un límite de 250 scripts autorizados. El selector estándar AdsApp.campaigns().get() excluye campañas de video y shopping. Los límites pueden cambiar sin aviso: el script tiene que tolerar errores.
  • Google Ads Scripts · AdsApp.Campaign, leída el 19-09-2026 (la página declara última actualización 10-09-2026): getStatsFor acepta un rango predefinido (TODAY, YESTERDAY, LAST_7_DAYS, LAST_14_DAYS, LAST_30_DAYS, LAST_WEEK, THIS_MONTH, LAST_MONTH, ALL_TIME, etc.) o dos fechas en YYYYMMDD, inclusivas en ambos extremos. El script usa la forma con dos fechas porque «los 28 días previos a los últimos 7» no es un rango predefinido. También usa getId(), getName() e isEnabled().
  • Google Ads Scripts · AdsApp.Stats, leída el 19-09-2026 (la página declara última actualización 10-09-2026): Métodos usados: getCost() (double, en la moneda de la cuenta), getClicks() (long), getImpressions() (long), getConversions() (double). El CTR y el CPA se recalculan en código a partir de clics, impresiones, costo y conversiones, en vez de leer getCtr(), para que la derivación sea la misma en ambas ventanas y en los totales.
  • Google Ads Scripts · AdsApp.CampaignSelector, leída el 19-09-2026 (la página declara última actualización 10-09-2026): Las condiciones se escriben con el nombre del recurso y la columna, sensibles a mayúsculas: campaign.status con valores ENABLED, PAUSED o REMOVED. El script filtra campaign.status = ENABLED salvo que CONFIG.incluirPausadas sea true. El iterador que devuelve get() se recorre con hasNext() y next().
  • Google Ads Scripts · AdsApp, leída el 19-09-2026 (la página declara última actualización 10-09-2026): AdsApp.campaigns() devuelve solo campañas de búsqueda y display; Performance Max tiene su propio selector (performanceMaxCampaigns()), y video y shopping también. currentAccount() da acceso a la zona horaria (getTimeZone()), la moneda (getCurrencyCode()) y el ID de cliente (getCustomerId(), formato 123-456-7890). getExecutionInfo().isPreview() dice si la corrida es una vista previa: el script no envía email en ese caso.
  • Google Ads Scripts · Dates and times, leída el 19-09-2026 (la página declara última actualización 10-09-2026): La zona horaria por defecto del entorno es America/Los_Angeles, no la de la cuenta. Para armar las fechas de las ventanas hay que usar Utilities.formatDate(fecha, zona, formato) con AdsApp.currentAccount().getTimeZone(); si no, en una cuenta de Argentina o México el «ayer» puede caer en otro día.
  • Google Ads Scripts · External Data Integration, leída el 19-09-2026 (la página declara última actualización 10-09-2026): Desde un script de Google Ads se puede leer una URL arbitraria (UrlFetchApp), enviar correo con HTML y adjuntos (MailApp) y leer o escribir hojas de cálculo de Google (SpreadsheetApp). Son los tres servicios que usa el script: la API del modelo, el email a la persona de cuentas y la hoja opcional de la cola.
  • Apps Script · UrlFetchApp.fetch(url, params), leída el 19-09-2026 (la página declara última actualización 15-05-2026): Parámetros usados: method ('post'), contentType ('application/json'), headers (mapa clave/valor), payload (la cadena JSON) y muteHttpExceptions: true, para que un error del proveedor no corte el script y la tabla se envíe igual sin narrativa. La respuesta expone getResponseCode() y getContentText().
  • Apps Script · HTTPResponse, leída el 19-09-2026 (la página declara última actualización 13-04-2026): getResponseCode() devuelve el código HTTP y getContentText() el cuerpo como cadena; el script solo parsea el JSON cuando el código es 200.
  • Apps Script · MailApp.sendEmail, leída el 19-09-2026 (la página declara última actualización 13-04-2026): Firma usada: sendEmail(destinatarios separados por coma, asunto, cuerpo de texto, opciones) con las opciones htmlBody (versión HTML para los clientes que la rindan) y name (nombre del remitente). La cuota se cuenta por destinatario; getRemainingDailyQuota() devuelve cuántos quedan en el día.
  • Apps Script · Quotas for Google Services, leída el 19-09-2026 (la página declara última actualización 03-09-2026): Cuotas diarias que tocan a este script: llamadas de URL Fetch, 20.000 por día en cuentas de consumidor (gmail.com) y 100.000 en Google Workspace; destinatarios de email, 100 por día en cuentas de consumidor y 1.500 en Workspace; hasta 50 destinatarios por mensaje; cuerpo de email de 200 KB (consumidor) o 400 KB (Workspace); respuesta de URL Fetch de hasta 50 MB. El límite de 6 minutos por ejecución de esa tabla es el de Apps Script genérico; en Google Ads Scripts rige el de 30 minutos de su propia página de límites.
  • Google Ads Scripts · Get started, leída el 19-09-2026 (la página declara última actualización 10-09-2026): Ruta en la interfaz: menú Herramientas → Acciones en bloque → Scripts (o buscar «Scripts» en la barra de búsqueda de Google Ads); botón + → Nuevo script; pegar el código reemplazando main(); Autorizar cuando lo pida (una vez por script); Vista previa para ver los logs sin ejecutar; Ejecutar; el historial de scripts muestra estado, cambios y logs. Misma ruta en cuentas de administrador.
  • Google Ads Scripts · Authorization, leída el 19-09-2026 (la página declara última actualización 10-09-2026): El script corre en nombre de quien lo autorizó, aunque esa persona no esté conectada. Si esa persona pierde acceso a la cuenta, el script deja de correr hasta que alguien con acceso lo reautorice: en una agencia conviene autorizarlo con un usuario de la agencia, no con el del cliente ni con el de alguien que puede irse. Los permisos se revisan en la página de seguridad de la cuenta, «Administrar el acceso de terceros».
  • Ayuda de Google Ads · Acerca de los scripts (programación), leída el 19-09-2026: Frecuencias disponibles: una vez, diaria, semanal o mensual, a una hora determinada. Se configura en la página Scripts, columna «Frecuencia»: pasar el cursor, clic en el lápiz, elegir día de la semana y hora, Guardar. Para este script: semanal, lunes temprano en la zona horaria de la cuenta, para que «ayer» sea el domingo y la ventana cubra una semana calendario completa.
  • Google Ads Scripts · AdsManagerApp.ManagedAccountSelector.executeInParallel, leída el 19-09-2026 (la página declara última actualización 10-09-2026): En una cuenta de administrador, AdsManagerApp.accounts().withIds([...]).executeInParallel('funcion', 'callback') ejecuta la función en cada cuenta cliente (cada una con su propia cuota) y luego el callback una vez, con una lista de ExecutionResult: getCustomerId(), getReturnValue() (una cadena, por ejemplo JSON.stringify de un objeto) y getError() (null si terminó bien). Máximo 50 cuentas por corrida; con más, lanza excepción y no procesa ninguna.
  • Claude API · Messages (POST /v1/messages), leída el 19-09-2026: Endpoint POST https://api.anthropic.com/v1/messages. Cuerpo: model, max_tokens (número máximo de tokens a generar; el modelo puede parar antes), system (cadena con las instrucciones) y messages (lista de turnos con role y content). Respuesta: content (lista de bloques; los de type 'text' traen el texto), stop_reason (end_turn, max_tokens, refusal, entre otros) y usage con input_tokens y output_tokens, que el script escribe al pie del email.
  • Claude API · Overview (headers requeridos), leída el 19-09-2026: Headers: autenticación con x-api-key (clave de la consola; sigue soportado) o Authorization: Bearer; anthropic-version obligatorio (2023-06-01); content-type application/json obligatorio. El script manda x-api-key, anthropic-version y content-type.
  • Claude API · Pricing, leída el 19-09-2026: Precios por millón de tokens (entrada / salida) leídos el 19-09-2026: Opus 5, USD 5 / 25; Sonnet 5, USD 2 / 10; Haiku 4.5, USD 1 / 5. La misma página aclara que el precio de Sonnet 5, anunciado como introductorio, quedó como precio estándar. Con estos precios se calcula la estimación de costo por resumen de la guía.

Los umbrales, mínimos, gravedades y supuestos de tokens son criterio operativo nuestro y están marcados como tales; los métodos, límites, cuotas y precios llevan su fuente y fecha. Cifras del ejemplo: inventadas. Si una fuente cambia, se corrige acá.

¿Querés que lo instalemos con tus umbrales por cliente?

El diagnóstico gratuito estima cuántas horas por semana se van en revisar cuentas a mano y qué parte puede absorber un harness como este. En la conversación que sigue definimos umbrales, mínimos y quién firma por cliente. Sin compromiso y sin secuencias automáticas.

Sin spam y sin secuencias automáticas: un humano lee cada pedido y responde en menos de 48 h hábiles.

Preguntas frecuentes

¿El script cambia pujas o presupuestos si detecta una anomalía?

No, y no puede: las únicas llamadas que hace a la cuenta son de lectura (campaigns, currentAccount, getExecutionInfo) y hay un test en nuestro repositorio que falla si aparece otra. Lo que llega a la persona de cuentas es un hecho («el CPA de esta campaña subió tanto contra la base»), no una acción. La decisión y el cambio los hace una persona, a mano y con su usuario, para que quede en el historial de cambios de Google Ads con nombre.

¿Por qué comparar contra veintiocho días y no contra la semana anterior?

Porque una sola semana de base es tan ruidosa como la que se está evaluando: un feriado o un fin de semana largo en cualquiera de las dos genera una «anomalía» que no lo es. Veintiocho días son cuatro semanas completas, así que la base incluye cada día de la semana cuatro veces y se promedia a la escala de la ventana actual. La base se puede cambiar en CONFIG.ventanas; con estacionalidad fuerte, algunas agencias usan una base más larga y un umbral más alto.

¿Qué pasa si el modelo inventa un número?

La compuerta de cifras lo rechaza. Después de que el modelo redacta, el código extrae cada número del texto y lo busca en la tabla que le dio, con la misma tolerancia que usamos en la plantilla de reportes. Si hay una cifra huérfana, se reintenta una vez con la lista de cifras rechazadas en el pedido; si vuelve a fallar, el email sale con la tabla sola y el motivo del rechazo al pie. Nunca sale un número que el código no haya calculado.

¿Necesito la clave del modelo para que el script sirva?

No. Con CONFIG.modelo.clave vacía, el script hace todo lo demás: compara ventanas, marca anomalías con el umbral y el mínimo de datos, y manda la tabla por email. La narrativa es una comodidad para leer más rápido el lunes; la detección es código determinista. Muchas agencias arrancan sin modelo un mes, ajustan umbrales por cliente y recién después agregan la redacción.

¿Cómo se usa con muchas cuentas de clientes?

Desde la cuenta de administrador (MCC), con la variante que está más abajo: AdsManagerApp.accounts().withIds([...]).executeInParallel(...) corre el mismo código en cada cuenta cliente, cada una con su propia cuota de tiempo, y un callback junta los resultados. La documentación limita executeInParallel a cincuenta cuentas por corrida; con más clientes, dos scripts con listas distintas.

¿Por qué la ventana termina ayer y no hoy?

Porque hoy está incompleto y las conversiones de Google Ads se atribuyen con demora: comparar medio día contra un promedio de días completos produce una caída falsa de conversiones cada semana. El script arma las fechas con Utilities.formatDate y la zona horaria de la cuenta, porque el entorno de scripts asume America/Los_Angeles por defecto y en una cuenta de Buenos Aires o Ciudad de México el «ayer» del servidor puede ser otro día.

¿Cuánto cuesta la parte del modelo?

Poco, y la cifra exacta depende de cuántas campañas tenga la cuenta. En la sección de costo publicamos los precios por millón de tokens con la fecha en que los leímos y una estimación derivada de supuestos explícitos de tokens por resumen; el email trae al pie los tokens reales de entrada y salida de cada corrida, así que a la segunda semana ya tenés el número de tu cuenta, no el nuestro.

¿Se puede adaptar a Meta Ads o a otras plataformas?

La lógica de anomalías y la compuerta de cifras son funciones puras de JavaScript que no dependen de Google Ads: reciben cuatro números por ventana y devuelven anomalías; reciben un texto y una tabla y devuelven cifras huérfanas. Lo que cambia es la extracción. Para Meta hay que leerlo por la Marketing API desde otro entorno (Apps Script sirve, con UrlFetchApp), y la plantilla de reporte mensual documenta los campos exactos.