La concordancia amplia te da alcance. Lo que no te dice es cuánto de ese alcance ya lo tenías. Este es uno de esos google ads scripts que contestan a una pregunta con un número: de todo lo que gastas en amplia, cuánto se fue a términos que ya cubrían tus keywords en frase o exacta, y cuánto se fue a expansión de verdad.
Todo gratis y sin correo: el código está en esta página, la hoja de cálculo se copia con un clic y el script es de solo lectura. Lo tienes junto al resto de herramientas gratuitas de Webble. Si lo que quieres es que alguien se ocupe de la cuenta, eso es nuestra gestión de Google Ads.
Gratis y sin peaje: ni correo, ni registro, ni formulario. El código está más abajo en esta misma página y la hoja de cálculo se copia con un clic.
Qué problema resuelve
Tienes una keyword en amplia. Te trae 400 términos de búsqueda. La pregunta que importa no es si esos términos son relevantes: es cuántos de ellos habrías capturado igual con tus keywords en frase o exacta. Esa parte del gasto no compra alcance nuevo, compra lo mismo dos veces.
Google tiene el dato y no lo pone fácil. En el informe de términos de búsqueda existe una columna «Palabra clave» que dice exactamente qué keyword activó cada término, pero viene desactivada por defecto: hay que ir al icono de columnas, sección Atributos, y marcarla a mano. Y existe otra columna, «Tipo de concordancia», que dice cómo resolvió Google el match. Casi nadie las cruza.
El script hace ese cruce por ti, campaña a campaña, y lo escribe en una hoja de cálculo.
Qué hace el script, exactamente
Aísla los términos que solo pudo activar la amplia. Se apoya en algo que está en la documentación de Google y que se confunde constantemente: hay dos campos de tipo de concordancia y no significan lo mismo.
| Lo que tú configuraste | Lo que Google hizo | |
|---|---|---|
| Campo | segments.keyword.info.match_type |
segments.search_term_match_type |
| Valores | 3: amplia, frase, exacta | 9: los tres, más near exact, near phrase, AI Max, Performance Max y dos sin especificar |
| Dónde se ve | Columna «Palabra clave» · oculta por defecto | Columna «Tipo de concordancia» |
La diferencia entre esas dos columnas es el desperdicio. Una keyword en exacta cuyos términos salen como near exact está pagando variantes. Una en amplia cuyos términos salen como amplia está en expansión pura. Eso es lo primero que verás en el informe.
Lo segundo es el solapamiento: para cada término de amplia, el script comprueba si su texto ya contiene una keyword tuya en frase o exacta. Si la contiene, lo habrías cogido igual. Es una cota inferior —solo coincidencia literal, no semántica— así que va como suelo, no como cifra final.
Cómo instalarlo, paso a paso
- Copia la hoja de cálculoPulsa «Copiar la hoja de cálculo» arriba: Google te crea tu propia copia, ya con el bloque SUMARY y las fórmulas montadas. Guarda su URL — con la original no tendrías permiso de escritura.
- Pega la URL en el scriptCambia
SPREADSHEET_URLpor la de tu copia. De paso ajustaDATE_RANGEyMIN_CLICKS. - Pégalo en la sección de scripts de la cuentaHerramientas y ajustes → Acciones masivas → Scripts → crear. Autoriza, ejecuta en modo vista previa y, cuando cuadre, prográmalo semanal.
El script no calcula el resumen: lo calcula la plantilla. Su hoja modelo lleva ocho columnas —campaña, grupo, keyword, término, tipo de concordancia, coste, conversiones y valor— y un bloque SUMARY que las suma con SUMIF. El script solo vuelca los términos debajo de la cabecera: no toca fórmulas ni formato.
La columna Keyword es la que Google esconde, escrita al lado del término, que es la única forma de leer las dos cosas juntas. Y no hay letras de columna fijas: el script localiza la tabla por el texto «Search Query» y coloca cada dato en la columna cuyo título lo pide, así que puedes reordenar o renombrar sin tocar el código.
Tarda entre unos segundos y un par de minutos según el tamaño de la cuenta. Google corta cualquier script a los 30 minutos sin avisar y sin guardar nada, y ahí es donde estas cosas suelen dejarte con la hoja a medias. Por eso el script lleva un presupuesto de tiempo propio, TIME_BUDGET_MIN, con 25 minutos por defecto: escribe primero el volcado y el bloque, y si se le agota para, deja la hoja utilizable y declara en Diagnóstico qué ha omitido. Lo que se pierde son los extras, nunca el informe. Si te pasa, esto es lo que conviene tocar y en qué orden:
| Ajuste | Por qué funciona | Qué pierdes |
|---|---|---|
1 · Sube MIN_CLICKS a 3 o 5 |
La mejor con diferencia. La cola larga es casi todas las filas y casi nada del gasto. | Los términos de un clic, que rara vez deciden algo. |
2 · Baja DATE_RANGE a 14 días |
Menos filas y menos ruido. | Muestra. Vigila la columna de conversiones antes de hacerlo. |
3 · Apaga SHEET_PER_CAMPAIGN |
Con muchas campañas es la etapa más cara: duplica la hoja modelo una vez por campaña. | La vista por campaña. El total de la cuenta se queda. |
4 · Filtra con CAMPAIGN_NAME_CONTAINS |
Parte el trabajo en dos o tres pases. | El total de la cuenta. Solo si lo anterior no basta. |
Cómo leer el resultado
La hoja modelo te da el total de la cuenta y hay una por campaña, cada una con su bloque. Compara el CPA de amplia con el de exacta dentro de la misma campaña: entre campañas no dice nada, porque cambian audiencia y puja.
El bloque da valor y ROAS, no solo CPA, porque en ecommerce el CPA por concordancia no decide nada. Y si no hay conversiones suficientes escribe «muestra corta» en vez de una cifra: un CPA sobre dos conversiones es ruido con decimales, y darlo parece un dato. El umbral lo pones tú en la celda amarilla.
Debajo hay seis pestañas más. En orden de utilidad:
| Pestaña | Qué te dice |
|---|---|
| Revisar | Términos que cumplen las cuatro cosas a la vez: los sirvió Google como amplia o frase, se han gastado por encima del umbral que marques, no los estás comprando como keyword y no han convertido. Con su coste total arriba. Son candidatos a negativo para que los mires tú, no una orden. |
| N-gramas | El patrón por palabras en vez del término suelto: ahí se ven las fugas por tema —«barata», «gratis», «segunda mano», «opiniones»—. Sus costes no suman el total, porque un término aporta el suyo a todos los gramos que contiene: sirve para ordenar temas, no para sumar. |
| Términos | El detalle, término a término. Lo que configuraste frente a lo que sirvió Google, si esa keyword la compras y si el término ya lo cubría una tuya más precisa. Ordena por coste y lee veinte filas: ahí está el 80 % de la decisión. |
| Histórico | Una fila por ejecución, y es la única pestaña que el script no borra. Un informe te dice dónde estás; la serie te dice si vas a mejor o a peor. Programa el script semanalmente y en un mes tienes la respuesta. |
| Panel | Los gráficos: coste por concordancia y la evolución del histórico. |
| Diagnóstico | Qué ha pasado en la ejecución, cuánto ha tardado y qué ha quedado fuera. Es la primera que hay que abrir cuando algo no cuadra. |
Con tu correo en EMAIL_TO te llega el resumen en tres líneas y el enlace. Y con EMAIL_ONLY_IF_BROAD_WORSE_PCT solo te escribe cuando el CPA de amplia se pasa del de exacta en el porcentaje que digas: la única forma de que un aviso semanal no acabe ignorado.
Ojo con una cosa que el bloque SUMARY no puede recoger: solo suma las cinco etiquetas de concordancia que existen como keyword. El gasto de Performance Max y AI Max no entra en ninguna de sus tres filas, y hace bien, porque no es amplia. La pestaña Diagnóstico lo desglosa para que no lo sumes a mano por error. Si esa parte pesa mucho, la conversación es otra: por qué Performance Max no debería ser la primera campaña.
Si al leer el informe la conclusión es que sobra estructura o que hay campañas peleándose entre ellas, el problema es anterior a la concordancia: los tipos de campaña y cuándo usar cada uno.
Lo que el script no puede decirte
Una herramienta que no declara sus límites acaba usándose para decidir cosas que no sostiene. Estos son los cinco del script, y todos van escritos también dentro de la hoja para que nadie los lea aquí y los olvide allí:
| Límite | Qué significa |
|---|---|
| Solo campañas de búsqueda | La consulta filtra por campañas de búsqueda. Performance Max no entra, y no por falta de ganas: PMax no tiene keywords, así que no existe el primer término de la comparación. AI Max sí aparece, porque vive dentro de campañas de búsqueda, y el bloque lo declara aparte en lugar de sumarlo como amplia. |
| El informe de términos no lo ve todo | Google solo publica los términos con volumen suficiente, así que el coste de la tabla nunca cuadra con el de tus campañas. Un análisis sobre más de 60 cuentas midió que el gasto sin término visible llegó al 29,9 % tras el cambio de septiembre de 2020. Cualquier porcentaje que saques de aquí es un suelo, no la realidad. El Diagnóstico enfrenta las dos cifras —coste atribuido a términos visibles y coste total de las campañas— en lugar de esconderlas. |
| El solapamiento es una cota inferior | Se mide por coincidencia literal de texto, no por significado. «Cubierta térmica» y «manta de piscina» son lo mismo para un cliente y dos cosas distintas para el script. Lo que marca es real; lo que no marca no está descartado. |
| Los n-gramas no suman | Un término de cuatro palabras aporta su coste a todos los gramos que contiene, así que esa columna está sobrecontada a propósito. Sirve para ordenar temas entre sí, nunca para sumar ni para calcular un porcentaje del total. |
| Un CPA sin muestra no es un CPA | Por debajo del umbral que marques, el bloque escribe «muestra corta» en lugar de una cifra. Es deliberado: dos conversiones dan un número con dos decimales y ninguna información, y eso se parece demasiado a un dato. |
Y el más importante, que no es técnico: el script te dice dónde se está yendo el dinero, no si sobra. Un término sin conversión atribuida puede estar asistiendo por otra vía, y la exploración tiene un coste legítimo. Por eso la pestaña se llama Revisar y no Negativos.
El código
Completo, comentado y gratis. Es de solo lectura: busca mutate, createLabel, pause o remove y no están. Lo único que escribe es tu hoja de cálculo.
impacto-concordancia-amplia.js · v4.0
/**
* ============================================================================
* IMPACTO REAL DE LA CONCORDANCIA AMPLIA · Google Ads Script
* ----------------------------------------------------------------------------
* Autor original : Manuel Martínez Peña — Webble · primera versión 2023
* Versión : 4.0 (agosto 2026) — Revisar, n-gramas, histórico, panel,
* correo, ROAS, muestra mínima y presupuesto de tiempo
* Licencia : uso libre, atribución apreciada
* Permisos : SOLO LECTURA sobre Google Ads. No llama a mutate(),
* mutateAll(), createLabel(), pause(), remove() ni bidding().
* Lo único que escribe es tu hoja de cálculo.
*
* CÓMO FUNCIONA LA PLANTILLA · LÉEME ANTES DE EJECUTAR
* La plantilla no necesita que el script calcule el resumen: ya lo calcula ella.
* Su hoja modelo tiene esta forma:
*
* cabecera · A-H : Campaign Name | Ad Group Name | Keyword | Search Query |
* Match Type | COST LAST 30 DAYS |
* CONVERSIONS LAST 30 DAYS | CONV VALUE LAST 30 DAYS
* debajo : los datos
* bloque SUMARY : Match Type | COST | CONVERSIONS | CPA | VALUE | ROAS,
* con SUMIF sobre la columna «Match Type». El CPA se calla
* y escribe «muestra corta» si no hay conversiones
* suficientes, porque un CPA sobre dos conversiones es
* ruido con decimales.
*
* Así que el script hace UNA cosa: volcar los términos debajo de la cabecera.
* El bloque SUMARY se rellena solo. El script no escribe en él, no toca
* fórmulas y no cambia el formato.
*
* NO HAY COORDENADAS FIJAS. El script localiza la fila de cabecera por el texto
* «Search Query» y después coloca cada dato en la columna cuyo TÍTULO lo pide
* («Keyword», «Match Type», «COST…»). Puedes reordenar columnas, renombrarlas
* en español, quitar alguna o mover el bloque de sitio: sigue funcionando.
*
* Los SUMIF buscan en la columna «Match Type» los textos exactos que usa la
* interfaz de Google Ads —«Exact», «exact (close variant)», «phrase», «phrase
* (close variant)», «broad»—, así que esa columna lleva el tipo con el que
* GOOGLE resolvió la subasta, escrito con esas palabras. Si no coincidieran,
* los SUMIF darían cero: es la causa número uno de un SUMARY en blanco.
*
* «Keyword» es la keyword TUYA que disparó ese término. Sale de
* segments.keyword.info.text. Va vacía cuando el término no viene de una
* keyword (dinámicas, Performance Max) o si tu API rechaza ese campo — mira la
* pestaña Diagnóstico, que lo dice.
*
* QUÉ ESCRIBE
* · La hoja modelo con TODAS las campañas juntas: el total de la cuenta.
* · Una hoja por campaña, duplicando la modelo para heredar el bloque.
* · «Revisar» — términos servidos como amplia o frase, con gasto por
* encima del umbral, que NO estás comprando como keyword y
* que no han convertido. Candidatos a negativo, no órdenes:
* el script no toca tu cuenta y no puede.
* · «N-gramas» — el patrón por palabras, donde se ven las fugas por tema.
* · «Términos» — el detalle con el cruce de tipos y el solapamiento.
* · «Histórico» — una fila por ejecución. La única pestaña que no se borra.
* · «Panel» — los gráficos, dibujados sobre datos del propio script.
* · «Diagnóstico»— qué ha pasado, cuánto ha tardado y qué ha quedado fuera.
*
* EL LÍMITE DE LOS 30 MINUTOS
* Google corta un script a los 30 minutos sin avisar y sin guardar nada. Para
* que eso no te deje con la hoja a medias, las etapas van ordenadas de más a
* menos importante y hay un presupuesto de tiempo (CONFIG.TIME_BUDGET_MIN, 25
* por defecto). El volcado y el bloque SUMARY se escriben primero; si el
* presupuesto se agota, el script para, deja la hoja utilizable y te dice en
* Diagnóstico qué ha omitido y cuál de los cuatro ajustes te conviene. Nunca
* recorta en silencio.
* ============================================================================
*/
/* ------------------------------ CONFIGURACIÓN ------------------------------ */
var CONFIG = {
// 1) URL de TU copia de la hoja de cálculo. Archivo → Hacer una copia.
SPREADSHEET_URL: 'PEGA_AQUI_LA_URL_DE_TU_COPIA',
// 2) Periodo. La plantilla dice «LAST 30 DAYS» en las cabeceras, así que si
// cambias esto, cambia también esos títulos a mano.
// LAST_7_DAYS · LAST_14_DAYS · LAST_30_DAYS · THIS_MONTH · LAST_MONTH
DATE_RANGE: 'LAST_30_DAYS',
// 3) Ignora términos por debajo de estos clics. 0 = cola larga completa.
MIN_CLICKS: 1,
// 4) Hoja que hace de modelo. Es la que trae la plantilla de Webble.
// Déjalo vacío para que la detecte sola buscando la cabecera «Search Query».
TEMPLATE_SHEET: 'RESUMEN',
// 5) Volcar además TODAS las campañas juntas en la hoja modelo, para que su
// SUMARY dé el total de la cuenta en lugar de quedarse a cero.
TEMPLATE_AS_TOTAL: true,
// 6) Una hoja por campaña, duplicando la modelo.
SHEET_PER_CAMPAIGN: true,
// 7) Pestañas añadidas al final. No tocan el diseño de la plantilla.
ADD_TERMS_SHEET: true, // detalle con expansión y solapamiento
ADD_DIAGNOSTICS_SHEET: true, // qué ha pasado en la ejecución
// 8) Si una hoja con el nombre de la campaña existe pero NO tiene el bloque
// SUMARY —por ejemplo porque la creó una versión anterior de este script—,
// se rehace a partir de la modelo. Ponlo en false si has editado esas hojas
// a mano y no quieres perder los cambios.
REBUILD_SHEETS_WITHOUT_BLOCK: true,
// 9) Solo campañas cuyo nombre contenga este texto. Vacío = todas.
CAMPAIGN_NAME_CONTAINS: '',
// 10) PRESUPUESTO DE TIEMPO, en minutos.
// Google corta la ejecución a los 30 minutos sin avisar y sin guardar nada.
// El script mira el reloj entre etapas, escribe PRIMERO el volcado y el
// bloque SUMARY, y si se pasa de este presupuesto para y te dice en
// Diagnóstico qué ha omitido y qué ajuste te conviene. Nunca recorta en
// silencio. Déjalo por debajo de 30 con margen.
TIME_BUDGET_MIN: 25,
// 11) Pestaña «Revisar»: términos que merecen una mirada humana.
// Entra un término cuando se cumplen las CUATRO cosas a la vez:
// lo sirvió Google como amplia o frase · se ha gastado al menos
// REVIEW_MIN_COST · NO lo estás comprando como keyword · no ha convertido.
// Es una lista para revisar, no para aplicar: el script no toca la cuenta.
ADD_REVIEW_SHEET: true,
REVIEW_MIN_COST: 10,
REVIEW_SERVED_TYPES: ['BROAD', 'PHRASE', 'NEAR_PHRASE'],
// 12) Pestaña «N-gramas»: el patrón por palabras en vez del término suelto.
// Ahí se ven las fugas por tema («barata», «gratis», «segunda mano»).
ADD_NGRAMS_SHEET: true,
NGRAM_SIZES: [1, 2, 3],
NGRAM_MAX_ROWS: 400, // por tamaño de gramo; lo recortado se declara
// 13) Pestaña «Histórico»: una fila por ejecución, para ver la tendencia.
// Es la única pestaña que el script NO borra: va acumulando.
ADD_HISTORY_SHEET: true,
// 14) Pestaña «Panel» con los gráficos.
ADD_CHARTS: true,
// 15) Aviso por correo al terminar. Vacío = no envía nada.
// Con EMAIL_ONLY_IF_BROAD_WORSE_PCT en, por ejemplo, 20, solo te escribe
// cuando el CPA de amplia se pasa del de exacta en más de un 20 %.
// Déjalo en null para recibirlo siempre.
EMAIL_TO: '',
EMAIL_ONLY_IF_BROAD_WORSE_PCT: null,
// 16) Muestra mínima. Un CPA calculado sobre 2 conversiones es ruido con
// decimales. Por debajo de este número de conversiones, la plantilla
// escribe «muestra corta» en lugar de una cifra con aire de certeza, y las
// pestañas del script hacen lo mismo.
MIN_CONVERSIONS_FOR_CPA: 5
};
/* ------------------------------- CONSTANTES ------------------------------- */
/**
* Traducción de segments.search_term_match_type al texto EXACTO que buscan los
* SUMIF de la plantilla, que es el de la interfaz de Google Ads. Si Google añade
* un tipo nuevo, se escribe tal cual y el diagnóstico avisa de que ningún SUMIF
* lo está sumando. SUMIF no distingue mayúsculas, así que el «Exact» con
* mayúscula de la plantilla y un «exact» en minúscula suman igual.
*/
var SERVED_LABELS = {
EXACT: 'Exact',
NEAR_EXACT: 'exact (close variant)',
PHRASE: 'phrase',
NEAR_PHRASE: 'phrase (close variant)',
BROAD: 'broad',
AI_MAX: 'AI Max',
PERFORMANCE_MAX: 'Performance Max'
};
// Los tipos que los SUMIF de la plantilla sí suman.
var SUMMED_TYPES = ['EXACT', 'NEAR_EXACT', 'PHRASE', 'NEAR_PHRASE', 'BROAD'];
// Cómo agrupa el bloque SUMARY: la variante aproximada cuenta con su tipo. El
// script usa este mismo mapa para que el Histórico y el Panel digan exactamente
// lo mismo que el bloque, y no una segunda versión de la verdad.
var SUMMARY_GROUP = {
EXACT: 'EXACT', NEAR_EXACT: 'EXACT',
PHRASE: 'PHRASE', NEAR_PHRASE: 'PHRASE',
BROAD: 'BROAD'
};
var SUMMARY_ORDER = ['EXACT', 'PHRASE', 'BROAD'];
// Tipos configurables en una keyword.
var CONFIGURED_TYPES = ['EXACT', 'PHRASE', 'BROAD'];
// Columnas del volcado. El script NO da por hecha ninguna posición: lee la fila
// de cabecera y coloca cada dato en la columna cuyo título lo pide. Así puedes
// reordenar, renombrar en español o quitar una columna sin romper el volcado.
var HEADER_PATTERNS = [
['campaign', /campaign\s*name|campa[ñn]a/i],
['adGroup', /ad\s*group|grupo\s*de\s*anuncios/i],
['keyword', /keyword|palabra\s*clave/i],
['term', /search\s*quer|t[ée]rmino\s*de\s*b[úu]squeda/i],
['match', /match\s*type|tipo\s*de\s*concordancia/i],
['cost', /cost|coste|gasto/i],
['value', /conv.*value|valor\s*de\s*conv|value.*conv/i],
['conv', /conversion|conversi[óo]n/i]
];
// Palabras que no dicen nada por sí solas. Se descartan como gramo de UNA
// palabra, pero se conservan dentro de los de dos y tres: «manta de piscina» sí
// significa algo. Añade las tuyas si tu sector tiene ruido propio.
var STOPWORDS = ('de la el en y a los del las un por con no una su para es al lo como mas más o '
+ 'que qué se me mi te tu su sin sobre entre desde hasta muy ya si sí').split(' ');
/* ------------------------- PRESUPUESTO DE TIEMPO ------------------------- */
/**
* Google mata el script a los 30 minutos sin guardar nada. Este reloj existe
* para que eso no pase nunca: se consulta entre etapas y, cuando el presupuesto
* se agota, el script deja de añadir cosas y se va a escribir el diagnóstico.
* Las etapas están ordenadas de más a menos importante a propósito, así que
* cuando esto salta lo que se pierde son los extras, no el informe.
*/
var CLOCK = { start: 0, budgetMs: 0, stages: [], tripped: false };
function clockStart() {
CLOCK.start = new Date().getTime();
CLOCK.budgetMs = Math.max(1, toNumber(CONFIG.TIME_BUDGET_MIN) || 25) * 60000;
CLOCK.stages = [];
CLOCK.tripped = false;
}
function elapsedMin() {
return Math.round((new Date().getTime() - CLOCK.start) / 600) / 100;
}
/** Anota cuánto ha costado una etapa. Es lo que luego elige la palanca a tocar. */
function stageDone(name) {
var prev = CLOCK.stages.length ? CLOCK.stages[CLOCK.stages.length - 1].at : 0;
CLOCK.stages.push({ name: name, at: elapsedMin(), cost: Math.round((elapsedMin() - prev) * 100) / 100 });
}
/** ¿Queda presupuesto para la etapa «name»? Si no, la declara omitida. */
function haveTimeFor(name, diag) {
if (new Date().getTime() - CLOCK.start < CLOCK.budgetMs) return true;
CLOCK.tripped = true;
diag.omitidas.push(name);
return false;
}
/** El reparto de tiempo por etapa, para el diagnóstico. */
function stageBreakdown() {
if (!CLOCK.stages.length) return 'sin etapas registradas';
var parts = [];
for (var i = 0; i < CLOCK.stages.length; i++) {
parts.push(CLOCK.stages[i].name + ' ' + CLOCK.stages[i].cost + ' min');
}
return parts.join(' · ');
}
/** La etapa que más tiempo se ha comido. */
function slowestStage() {
var worst = null;
for (var i = 0; i < CLOCK.stages.length; i++) {
if (!worst || CLOCK.stages[i].cost > worst.cost) worst = CLOCK.stages[i];
}
return worst;
}
/**
* El aviso que se escribe cuando el presupuesto se agota. No dice «reduce
* cosas»: dice cuál de las cuatro palancas conviene según por dónde se ha ido el
* tiempo, y en qué orden, porque no valen lo mismo.
*/
function timeAdvice(diag) {
var worst = slowestStage();
var palancas = [];
palancas.push('1 · Sube MIN_CLICKS de ' + CONFIG.MIN_CLICKS + ' a 3 o 5. Es la mejor con '
+ 'diferencia: la cola larga es casi todas las filas y casi nada del gasto, así que cortas '
+ 'mucho volumen perdiendo muy poco coste.');
palancas.push('2 · Baja DATE_RANGE de ' + CONFIG.DATE_RANGE + ' a LAST_14_DAYS. Menos filas, '
+ 'pero también menos muestra para el CPA: mira antes la columna de conversiones.');
if (CONFIG.SHEET_PER_CAMPAIGN && diag.campanas > 5) {
palancas.push('3 · Apaga SHEET_PER_CAMPAIGN. Con ' + diag.campanas + ' campañas es la etapa '
+ 'más cara del script, porque duplica la hoja modelo una vez por campaña.');
}
palancas.push((palancas.length + 1) + ' · Filtra con CAMPAIGN_NAME_CONTAINS y ejecútalo en dos o '
+ 'tres pases. Solo si las anteriores no bastan: pierdes el total de la cuenta.');
diag.avisos.push('PRESUPUESTO DE TIEMPO AGOTADO tras ' + elapsedMin() + ' min de los '
+ CONFIG.TIME_BUDGET_MIN + ' de TIME_BUDGET_MIN. He parado a propósito: Google corta a los 30 '
+ 'minutos sin guardar nada, así que he escrito primero lo que importa. EL VOLCADO Y EL BLOQUE '
+ 'SUMARY ESTÁN COMPLETOS Y SON VÁLIDOS. Lo omitido son los extras, y está en la línea «Etapas '
+ 'omitidas» de arriba.'
+ (worst ? ' La etapa más lenta ha sido «' + worst.name + '» (' + worst.cost + ' min).' : '')
+ ' Qué tocar, en este orden: ' + palancas.join(' ')
+ ' Lo que NO recomiendo es analizar otra cuenta más pequeña: si el problema es el tamaño de '
+ 'esta, la respuesta no es mirar otra.');
}
/* ---------------------------------- MAIN ---------------------------------- */
/**
* Las etapas van ordenadas de más a menos importante, no por comodidad de
* lectura: si el presupuesto de tiempo se agota, lo que se queda fuera son los
* extras y nunca el informe. El volcado y el bloque SUMARY se escriben antes de
* consultar el inventario de keywords precisamente por eso.
*/
function main() {
var ss = openSpreadsheet();
var diag = newDiagnostics();
clockStart();
try {
/* — ETAPA 1 · leer los términos — */
var rows = fetchSearchTerms(diag);
diag.filas = rows.length;
stageDone('lectura de términos');
var data = aggregate(rows);
diag.campanas = Object.keys(data.campaigns).length;
if (!rows.length) {
diag.avisos.push('La consulta no ha devuelto ni una fila. Revisa el periodo ('
+ CONFIG.DATE_RANGE + '), el umbral de clics (' + CONFIG.MIN_CLICKS + ') y que la cuenta '
+ 'tenga campañas de BÚSQUEDA con gasto. En cuentas solo de Performance Max o Shopping '
+ 'este informe sale vacío por definición: ese tráfico no tiene keyword.');
}
/* — ETAPA 2 · el informe: volcado y bloque SUMARY — */
var layout = findTemplateLayout(ss, diag);
if (!layout) {
diag.avisos.push('No he encontrado la hoja modelo. Busco una hoja con la cabecera «Search '
+ 'Query» y un bloque con «Match Type». Si la tuya está distinta, pon su nombre en '
+ 'CONFIG.TEMPLATE_SHEET. Mientras tanto he dejado el detalle en la pestaña «Términos».');
} else {
if (CONFIG.TEMPLATE_AS_TOTAL) {
dumpTerms(ss.getSheetByName(layout.sheetName), layout, data.terms, diag,
'Todas las campañas (' + diag.campanas + ')');
diag.hojasEscritas++;
}
stageDone('volcado en la hoja modelo');
if (CONFIG.SHEET_PER_CAMPAIGN) {
fillCampaignSheets(ss, layout, data, diag);
stageDone('una hoja por campaña');
}
}
checkUnsummedSpend(data, diag);
/* — ETAPA 3 · el inventario de keywords, del que dependen los extras — */
var lexicon = null;
if (rows.length && haveTimeFor('inventario de keywords de la cuenta', diag)) {
lexicon = buildKeywordLexicon();
enrichOverlap(data, lexicon);
diag.keywordsInventario = lexicon.count;
stageDone('inventario de keywords');
} else if (rows.length) {
diag.avisos.push('Sin el inventario de keywords no puedo saber qué términos NO estás '
+ 'comprando, así que las pestañas «Revisar» y la columna de solapamiento se quedan fuera '
+ 'de esta ejecución. El volcado y el SUMARY no dependen de eso y son correctos.');
}
/* — ETAPA 4 · los extras, en orden de utilidad — */
if (CONFIG.ADD_REVIEW_SHEET && lexicon && haveTimeFor('pestaña Revisar', diag)) {
diag.revisar = writeReviewSheet(ss, data, diag);
stageDone('pestaña Revisar');
}
if (CONFIG.ADD_NGRAMS_SHEET && rows.length && haveTimeFor('pestaña N-gramas', diag)) {
writeNgramsSheet(ss, data, diag);
stageDone('pestaña N-gramas');
}
if (CONFIG.ADD_TERMS_SHEET && rows.length && haveTimeFor('pestaña Términos', diag)) {
writeTermsSheet(ss, data);
stageDone('pestaña Términos');
}
if (CONFIG.ADD_HISTORY_SHEET && rows.length && haveTimeFor('pestaña Histórico', diag)) {
appendHistoryRow(ss, data, diag);
stageDone('pestaña Histórico');
}
if (CONFIG.ADD_CHARTS && rows.length && haveTimeFor('pestaña Panel con los gráficos', diag)) {
writePanelSheet(ss, data, diag);
stageDone('pestaña Panel');
}
/* — El correo va ANTES del diagnóstico, no después: así el diagnóstico
puede decir si se envió, a quién, o por qué se calló. — */
if (CLOCK.tripped) timeAdvice(diag);
sendSummaryEmail(ss, data, diag);
/* — El diagnóstico se escribe SIEMPRE, agotado el presupuesto o no — */
diag.duracion = elapsedMin();
if (CONFIG.ADD_DIAGNOSTICS_SHEET) writeDiagnosticsSheet(ss, diag, data);
Logger.log('Listo en ' + elapsedMin() + ' min. ' + diag.filas + ' términos en ' + diag.campanas
+ ' campañas · ' + diag.hojasEscritas + ' hojas rellenadas, ' + diag.hojasCreadas + ' creadas.'
+ (diag.omitidas.length ? ' OMITIDO por presupuesto de tiempo: ' + diag.omitidas.join(', ')
+ ' — mira la pestaña Diagnóstico.' : ''));
Logger.log(ss.getUrl());
} catch (e) {
diag.avisos.push('ERROR: ' + (e && e.message ? e.message : e));
if (CONFIG.ADD_DIAGNOSTICS_SHEET) {
try { writeDiagnosticsSheet(ss, diag, null); } catch (e2) { /* nada que hacer */ }
}
Logger.log('ERROR: ' + e);
throw e;
}
}
function newDiagnostics() {
return {
cuenta: safe(function () {
return AdsApp.currentAccount().getName() + ' (' + AdsApp.currentAccount().getCustomerId() + ')';
}),
moneda: safe(function () { return AdsApp.currentAccount().getCurrencyCode(); }),
generado: safe(function () {
return Utilities.formatDate(new Date(), AdsApp.currentAccount().getTimeZone(), 'yyyy-MM-dd HH:mm');
}),
periodo: CONFIG.DATE_RANGE,
minClics: CONFIG.MIN_CLICKS,
filas: 0,
campanas: 0,
modelo: '',
hojasEscritas: 0,
hojasCreadas: 0,
hojasRehechas: 0,
rangosAmpliados: 0,
omitidas: [],
duracion: 0,
keywordsInventario: 0,
revisar: null,
ngramas: '',
historico: '',
graficos: '',
correo: 'no configurado',
keywordText: 'sí',
avisos: []
};
}
function safe(fn) {
try { return fn(); } catch (e) { return 'no disponible'; }
}
/* ---------------------------- 1 · LECTURA DE DATOS ---------------------------- */
/**
* segments.keyword.info.text es el único campo de la consulta que Google no
* documenta en su recetario oficial. Si tu versión de la API no lo acepta, la
* consulta ENTERA falla y no se escribe nada. Por eso se intenta con él y, si
* revienta, se reintenta sin él: se pierde la columna «Keyword» del volcado y de
* la pestaña «Términos», y nada más. El bloque SUMARY y el solapamiento no
* dependen de ese campo — el solapamiento se mide sobre el texto del término.
*/
function fetchSearchTerms(diag) {
var withText = true;
var it;
try {
it = AdsApp.search(buildQuery(true));
} catch (e) {
withText = false;
diag.keywordText = 'NO — la API rechazó segments.keyword.info.text';
diag.avisos.push('Sin ese campo la columna «Keyword» del volcado y de «Términos» sale vacía. '
+ 'Todo lo demás sigue siendo válido: el bloque SUMARY suma sobre «Match Type» y el '
+ 'solapamiento se mide sobre el texto del término, no sobre el de la keyword. '
+ 'Mensaje de la API: ' + (e && e.message ? e.message : e));
it = AdsApp.search(buildQuery(false));
}
var out = [];
while (it.hasNext()) {
var row = it.next();
var campaignName = row.campaign.name;
if (CONFIG.CAMPAIGN_NAME_CONTAINS &&
campaignName.toLowerCase().indexOf(CONFIG.CAMPAIGN_NAME_CONTAINS.toLowerCase()) === -1) {
continue;
}
var kw = (row.segments && row.segments.keyword) ? row.segments.keyword : null;
out.push({
campaign: campaignName,
adGroup: row.adGroup ? row.adGroup.name : '',
term: row.searchTermView.searchTerm,
status: row.searchTermView.status,
keyword: (withText && kw && kw.info) ? (kw.info.text || '') : '',
configured: (kw && kw.info && kw.info.matchType) ? kw.info.matchType : 'SIN KEYWORD',
served: row.segments.searchTermMatchType || 'UNKNOWN',
impressions: toNumber(row.metrics.impressions),
clicks: toNumber(row.metrics.clicks),
cost: toNumber(row.metrics.costMicros) / 1000000,
conversions: toNumber(row.metrics.conversions),
value: toNumber(row.metrics.conversionsValue)
});
}
return out;
}
/** La consulta del recetario oficial de Google, más el tipo servido. */
function buildQuery(withKeywordText) {
return 'SELECT ' +
' search_term_view.search_term, ' +
' search_term_view.status, ' +
(withKeywordText ? ' segments.keyword.info.text, ' : '') +
' segments.keyword.info.match_type, ' +
' segments.search_term_match_type, ' +
' campaign.id, ' +
' campaign.name, ' +
' ad_group.name, ' +
' metrics.impressions, ' +
' metrics.clicks, ' +
' metrics.cost_micros, ' +
' metrics.conversions, ' +
' metrics.conversions_value ' +
'FROM search_term_view ' +
'WHERE segments.date DURING ' + CONFIG.DATE_RANGE + ' ' +
' AND campaign.advertising_channel_type = "SEARCH" ' +
' AND metrics.clicks >= ' + CONFIG.MIN_CLICKS;
}
/**
* Inventario de keywords activas. Sirve para dos cosas distintas:
* · lex.PHRASE / lex.EXACT — medir el solapamiento (¿este término ya lo cubría
* una keyword mía más precisa?). Es una comparación por contención de texto.
* · lex.owned — saber si el término EXISTE como keyword tuya, en cualquier
* concordancia. Es una comparación por igualdad exacta del texto normalizado,
* y es el criterio que decide quién entra en la pestaña «Revisar».
* Las dos preguntas se parecen y no son la misma: un término puede solapar con
* una keyword tuya sin que tú lo estés comprando.
*/
function buildKeywordLexicon() {
var lex = { PHRASE: [], EXACT: [], owned: {}, count: 0 };
var it = AdsApp.keywords()
.withCondition('Status = ENABLED')
.withCondition('CampaignStatus = ENABLED')
.get();
while (it.hasNext()) {
var kw = it.next();
var type = kw.getMatchType(); // solo BROAD, PHRASE o EXACT
var text = normalize(kw.getText());
if (!text) continue;
if (type === 'PHRASE' || type === 'EXACT') lex[type].push(text);
if (!lex.owned[text]) { lex.owned[text] = type; lex.count++; }
}
return lex;
}
/**
* Añade a cada término las dos marcas que dependen del inventario. Va aparte de
* aggregate() a propósito: así el volcado y el SUMARY se escriben sin esperar a
* la consulta de keywords, que en cuentas grandes es de las más lentas.
*/
function enrichOverlap(data, lexicon) {
for (var i = 0; i < data.terms.length; i++) {
var r = data.terms[i];
var t = normalize(r.term);
// ¿La estás comprando? Igualdad exacta del texto, en cualquier concordancia.
r.owned = !!lexicon.owned[t];
r.ownedAs = r.owned ? lexicon.owned[t] : '';
// Solapamiento: cota INFERIOR, solo léxica. Solo tiene sentido en amplia.
r.overlap = false;
if (r.configured === 'BROAD') {
r.overlap = containsAny(t, lexicon.PHRASE) || containsAny(t, lexicon.EXACT);
}
}
}
/* --------------------------- 2 · AGREGACIÓN Y CRUCE --------------------------- */
function aggregate(rows) {
var campaigns = {};
var totals = blankBucket();
var byServed = {};
var byGroup = {};
var terms = [];
for (var i = 0; i < rows.length; i++) {
var r = rows[i];
if (!campaigns[r.campaign]) campaigns[r.campaign] = { terms: [], total: blankBucket() };
campaigns[r.campaign].terms.push(r);
add(campaigns[r.campaign].total, r);
add(totals, r);
bump(byServed, r.served, r);
// Y agrupado como lo agrupa el bloque SUMARY: exacta, frase y amplia juntas
// con sus variantes aproximadas. Es lo que va al Histórico y al Panel.
var g = SUMMARY_GROUP[r.served];
if (g) bump(byGroup, g, r);
r.owned = false;
r.ownedAs = '';
r.overlap = false;
terms.push(r);
}
return { campaigns: campaigns, totals: totals, byServed: byServed, byGroup: byGroup, terms: terms };
}
function blankBucket() {
return { impressions: 0, clicks: 0, cost: 0, conversions: 0, value: 0, rows: 0 };
}
function add(b, r) {
b.impressions += r.impressions;
b.clicks += r.clicks;
b.cost += r.cost;
b.conversions += r.conversions;
b.value += r.value;
b.rows += 1;
}
function bump(map, key, r) {
if (!map[key]) map[key] = blankBucket();
add(map[key], r);
}
/* -------------------- 3 · LOCALIZAR LA HOJA MODELO Y SU FORMA -------------------- */
/**
* Busca la hoja modelo sin dar por hecha ninguna coordenada: localiza la fila
* de cabecera que contiene «Search Query» y deduce de ahí dónde empiezan los
* datos. Devuelve solo coordenadas; no escribe nada.
*/
function findTemplateLayout(ss, diag) {
var sheets = CONFIG.TEMPLATE_SHEET
? [ss.getSheetByName(CONFIG.TEMPLATE_SHEET)].filter(Boolean)
: ss.getSheets();
if (CONFIG.TEMPLATE_SHEET && !sheets.length) {
diag.avisos.push('CONFIG.TEMPLATE_SHEET apunta a «' + CONFIG.TEMPLATE_SHEET
+ '» y no existe ninguna hoja con ese nombre.');
}
for (var s = 0; s < sheets.length; s++) {
var layout = readLayout(sheets[s]);
if (layout) {
diag.modelo = layout.sheetName + ' · cabecera en la fila ' + layout.headerRow
+ ', datos desde la ' + layout.firstDataRow
+ ' · ' + layout.width + ' columnas desde ' + colLetter(layout.firstCol)
+ ' (' + describeCols(layout) + ')'
+ (layout.hasBlock ? ' · bloque SUMARY detectado' : ' · SIN bloque SUMARY');
if (layout.cols.keyword === undefined) {
diag.avisos.push('La hoja modelo no tiene columna «Keyword», así que el volcado no la '
+ 'escribe. Si la quieres, añade una columna con ese título antes de «Search Query»: el '
+ 'script la detecta sola, sin tocar el código.');
}
return layout;
}
}
return null;
}
/** Devuelve la forma de la hoja si parece la modelo, o null. */
function readLayout(sheet) {
if (!sheet || sheet.getLastRow() === 0) return null;
var values = sheet.getDataRange().getValues();
for (var r = 0; r < Math.min(values.length, 30); r++) {
var row = values[r] || [];
var isHeader = false;
for (var c = 0; c < row.length; c++) {
if (/search\s*quer/i.test(String(row[c] || ''))) { isHeader = true; break; }
}
if (!isHeader) continue;
var map = mapHeader(row);
if (!map) continue;
return {
sheetName: sheet.getName(),
headerRow: r + 1,
firstDataRow: r + 2,
firstCol: map.firstCol,
width: map.width,
cols: map.cols,
hasBlock: rowHasBlock(values)
};
}
return null;
}
/**
* Traduce la fila de cabecera a posiciones. Devuelve la primera columna del
* volcado, su anchura y el desplazamiento de cada dato dentro de ella. Al ir por
* título y no por letra de columna, añadir «Keyword» delante de «Search Query»
* —o cualquier otro cambio de orden— no obliga a tocar el código.
*/
function mapHeader(row) {
var cols = {};
var min = null, max = null;
for (var c = 0; c < row.length; c++) {
var txt = String(row[c] || '').trim();
if (!txt) continue;
for (var p = 0; p < HEADER_PATTERNS.length; p++) {
var name = HEADER_PATTERNS[p][0];
if (cols[name] !== undefined) continue;
if (!HEADER_PATTERNS[p][1].test(txt)) continue;
cols[name] = c;
if (min === null || c < min) min = c;
if (max === null || c > max) max = c;
break;
}
}
if (cols.term === undefined || cols.match === undefined || min === null) return null;
var rel = {};
for (var k in cols) if (cols.hasOwnProperty(k)) rel[k] = cols[k] - min;
return { firstCol: min + 1, width: max - min + 1, cols: rel };
}
/** Letra de columna a partir de su número (1 → A). Solo para el diagnóstico. */
function colLetter(n) {
var s = '';
while (n > 0) {
var m = (n - 1) % 26;
s = String.fromCharCode(65 + m) + s;
n = Math.floor((n - 1) / 26);
}
return s;
}
/** Qué columna ha tocado a cada dato, para que el diagnóstico sea comprobable. */
function describeCols(layout) {
var parts = [];
for (var p = 0; p < HEADER_PATTERNS.length; p++) {
var name = HEADER_PATTERNS[p][0];
var off = layout.cols[name];
parts.push(name + '=' + (off === undefined ? '—' : colLetter(layout.firstCol + off)));
}
return parts.join(' ');
}
/** Una fila del volcado, colocada según el mapa de la cabecera. */
function layoutRow(layout, r) {
var out = [];
for (var i = 0; i < layout.width; i++) out.push('');
var c = layout.cols;
if (c.campaign !== undefined) out[c.campaign] = r.campaign;
if (c.adGroup !== undefined) out[c.adGroup] = r.adGroup;
if (c.keyword !== undefined) out[c.keyword] = r.keyword;
if (c.term !== undefined) out[c.term] = r.term;
if (c.match !== undefined) out[c.match] = servedLabel(r.served);
if (c.cost !== undefined) out[c.cost] = round(r.cost);
if (c.conv !== undefined) out[c.conv] = round(r.conversions);
if (c.value !== undefined) out[c.value] = round(r.value);
return out;
}
/** ¿Existe en algún sitio de la hoja el bloque con «Match Type» fuera de la cabecera? */
function rowHasBlock(values) {
var hits = 0;
for (var r = 0; r < Math.min(values.length, 30); r++) {
var row = values[r] || [];
for (var c = 0; c < row.length; c++) {
if (/match\s*type/i.test(String(row[c] || ''))) hits++;
}
}
return hits >= 2; // uno en la cabecera del volcado y otro en el bloque
}
/* ------------------------------ 4 · EL VOLCADO ------------------------------ */
/**
* Vuelca los términos en A6:F. Es lo único que hace falta: el bloque SUMARY se
* calcula solo. Limpia antes el contenido del volcado anterior —solo contenido,
* ni formatos ni fórmulas— para que no queden filas viejas sumando.
*/
function dumpTerms(sheet, layout, terms, diag, scope) {
var first = layout.firstDataRow;
var maxRow = sheet.getMaxRows();
// 0 · ficha de la cabecera: alcance, periodo, cuándo y cuántos términos
fillLabels(sheet, [
['Alcance', scope || 'Todas las campañas'],
['Periodo', CONFIG.DATE_RANGE],
['Generado', diag.generado],
['Términos', terms.length]
]);
// 1 · limpiar el volcado anterior, sin tocar nada más
if (maxRow >= first) {
sheet.getRange(first, layout.firstCol, maxRow - first + 1, layout.width).clearContent();
}
if (!terms.length) return 0;
// 2 · escribir, ordenado por coste descendente
var rows = terms.slice().sort(function (a, b) { return b.cost - a.cost; });
var out = [];
for (var i = 0; i < rows.length; i++) out.push(layoutRow(layout, rows[i]));
var needed = first + out.length - 1;
if (needed > sheet.getMaxRows()) sheet.insertRowsAfter(sheet.getMaxRows(), needed - sheet.getMaxRows());
sheet.getRange(first, layout.firstCol, out.length, layout.width).setValues(out);
// 3 · si el volcado pasa del límite que tenían los SUMIF, ampliarlo
if (layout.hasBlock) extendSumifRanges(sheet, needed, diag);
return out.length;
}
/**
* Los SUMIF de la plantilla llegan hasta la fila 1001. Si el volcado es más
* largo, el resumen se dejaría datos fuera en silencio. Esto amplía solo el
* número de fila final de cada rango, conservando la fórmula tal cual.
*/
function extendSumifRanges(sheet, lastDataRow, diag) {
var formulas = sheet.getDataRange().getFormulas();
var ampliadas = 0;
for (var r = 0; r < formulas.length; r++) {
for (var c = 0; c < formulas[r].length; c++) {
var f = formulas[r][c];
if (!f || f.indexOf('SUMIF') === -1) continue;
var bound = null;
var re = /\$?[A-Z]{1,2}\$?\d+:\$?[A-Z]{1,2}\$?(\d+)/g;
var m;
while ((m = re.exec(f)) !== null) {
var end = parseInt(m[1], 10);
if (bound === null || end < bound) bound = end;
}
if (bound === null || bound >= lastDataRow) continue;
var nuevo = f.replace(/(\$?[A-Z]{1,2}\$?\d+:\$?[A-Z]{1,2}\$?)(\d+)/g,
function (all, head, num) {
return parseInt(num, 10) === bound ? head + lastDataRow : all;
});
if (nuevo !== f) {
sheet.getRange(r + 1, c + 1).setFormula(nuevo);
ampliadas++;
}
}
}
if (ampliadas) {
diag.rangosAmpliados += ampliadas;
diag.avisos.push('En «' + sheet.getName() + '» el volcado llega a la fila ' + lastDataRow
+ ' y los SUMIF solo cubrían hasta antes: he ampliado ' + ampliadas + ' fórmulas hasta esa '
+ 'fila para que el SUMARY no se deje datos fuera. La fórmula es la misma, solo cambia el '
+ 'número de fila final.');
}
}
/**
* Rellena la ficha de la cabecera buscando el texto de cada etiqueta en las
* primeras filas y escribiendo en la celda de al lado. Al ir por texto y no por
* coordenadas, sigue funcionando si mueves el bloque o añades filas arriba.
*/
function fillLabels(sheet, pairs) {
var values = sheet.getDataRange().getValues();
for (var r = 0; r < Math.min(values.length, 12); r++) {
var row = values[r] || [];
for (var c = 0; c < row.length; c++) {
var txt = String(row[c] || '').trim();
for (var i = 0; i < pairs.length; i++) {
if (txt.toLowerCase() === pairs[i][0].toLowerCase()) {
sheet.getRange(r + 1, c + 2).setValue(pairs[i][1]);
}
}
}
}
}
/** Texto de la columna «Match Type». Tiene que coincidir con lo que buscan los SUMIF. */
function servedLabel(served) {
return SERVED_LABELS[served] || String(served || '').toLowerCase();
}
/* --------------------- 5 · UNA HOJA POR CAMPAÑA --------------------- */
function fillCampaignSheets(ss, layout, data, diag) {
var template = ss.getSheetByName(layout.sheetName);
var names = Object.keys(data.campaigns).sort();
for (var i = 0; i < names.length; i++) {
var campaign = names[i];
if (sheetName(campaign) === layout.sheetName) continue; // ya volcada como total
// Con muchas campañas esta es la etapa más cara del script, así que el reloj
// se mira campaña a campaña y no solo al principio del bucle.
if (!haveTimeFor('hojas de las campañas restantes (' + (names.length - i) + ')', diag)) {
diag.avisos.push('Me he quedado sin presupuesto de tiempo con ' + (names.length - i)
+ ' campañas por volcar. Las que faltan no tienen hoja propia en esta ejecución, pero la '
+ 'hoja modelo con el TOTAL de la cuenta sí está completa. Si esto te pasa cada semana, '
+ 'apaga SHEET_PER_CAMPAIGN o sube MIN_CLICKS.');
return;
}
var sheet = ensureCampaignSheet(ss, template, campaign, diag);
if (!sheet) continue;
var own = readLayout(sheet) || layout;
own.hasBlock = own.hasBlock || layout.hasBlock;
dumpTerms(sheet, own, data.campaigns[campaign].terms, diag, campaign);
diag.hojasEscritas++;
}
}
/**
* Devuelve la hoja de esa campaña con la forma de la modelo. Si no existe, la
* duplica para heredar el bloque SUMARY y sus fórmulas.
*/
function ensureCampaignSheet(ss, template, campaign, diag) {
var name = sheetName(campaign);
var existing = ss.getSheetByName(name);
if (existing) {
if (readLayout(existing)) return existing; // ya tiene la forma correcta
if (!CONFIG.REBUILD_SHEETS_WITHOUT_BLOCK) {
diag.avisos.push('La hoja «' + name + '» existe pero no tiene el bloque SUMARY, y '
+ 'REBUILD_SHEETS_WITHOUT_BLOCK está en false, así que la he dejado como estaba.');
return null;
}
// La creó una versión anterior de este script con otro formato: se rehace.
ss.deleteSheet(existing);
diag.hojasRehechas++;
}
try {
var copy = template.copyTo(ss);
copy.setName(name);
diag.hojasCreadas++;
return copy;
} catch (e) {
diag.avisos.push('No he podido crear la hoja «' + name + '»: ' + e);
return null;
}
}
/** Nombres de hoja: 100 caracteres y sin : \ / ? * [ ] */
function sheetName(name) {
return String(name).replace(/[:\\\/\?\*\[\]]/g, '-').substring(0, 95);
}
/* ------------------------- 6 · PESTAÑAS ADICIONALES ------------------------- */
/**
* Gasto que la columna D lleva con una etiqueta que ningún SUMIF de la plantilla
* suma. Es tráfico real que no aparece en el bloque, así que hay que declararlo.
*/
function checkUnsummedSpend(data, diag) {
var fuera = [];
var coste = 0;
for (var served in data.byServed) {
if (!data.byServed.hasOwnProperty(served)) continue;
if (SUMMED_TYPES.indexOf(served) !== -1) continue;
fuera.push(servedLabel(served) + ' (' + round(data.byServed[served].cost) + ')');
coste += data.byServed[served].cost;
}
if (fuera.length) {
diag.avisos.push('El bloque SUMARY solo suma Exact, exact (close variant), phrase, phrase '
+ '(close variant) y broad. Hay ' + round(coste) + ' de coste con otras etiquetas que no '
+ 'entran en ninguna de las tres filas: ' + fuera.join(', ') + '. Es tráfico sin tipo de '
+ 'concordancia atribuible, normalmente Performance Max o AI Max. No lo sumes a mano a '
+ 'ninguna fila: no es amplia.');
}
}
/* ---------------------------- PESTAÑA «REVISAR» ---------------------------- */
/**
* Términos que merecen una mirada humana. Entra un término cuando se cumplen las
* CUATRO condiciones a la vez:
*
* 1 · Google lo sirvió como amplia o frase (incluida la variante aproximada).
* 2 · Se ha gastado al menos CONFIG.REVIEW_MIN_COST.
* 3 · NO lo estás comprando: su texto no existe como keyword activa tuya.
* 4 · No ha convertido: cero conversiones en el periodo.
*
* Es una lista PARA REVISAR, no para aplicar. El script no negativiza nada, no
* puede: es de solo lectura. La decisión es de quien conoce el negocio, porque un
* término sin conversión atribuida puede estar asistiendo por otra vía.
*/
function writeReviewSheet(ss, data, diag) {
var moneda = diag.moneda === 'no disponible' ? '' : ' ' + diag.moneda;
var umbral = toNumber(CONFIG.REVIEW_MIN_COST);
var tipos = CONFIG.REVIEW_SERVED_TYPES || [];
var candidatos = [];
var costeTotal = 0;
for (var i = 0; i < data.terms.length; i++) {
var r = data.terms[i];
if (tipos.indexOf(r.served) === -1) continue;
if (r.cost < umbral) continue;
if (r.owned) continue;
if (r.conversions > 0) continue;
candidatos.push(r);
costeTotal += r.cost;
}
candidatos.sort(function (a, b) { return b.cost - a.cost; });
var sheet = resetSheet(ss, 'Revisar');
var out = [];
out.push(['TÉRMINOS A REVISAR · posibles negativos, no una orden']);
out.push(['El script es de solo lectura y no ha tocado tu cuenta. Esta lista es para que la mires tú.']);
out.push([]);
out.push(['Términos que cumplen las cuatro condiciones', candidatos.length]);
out.push(['Coste que representan', round(costeTotal) + moneda]);
out.push(['% del coste de los términos visibles',
data.totals.cost > 0 ? pct(costeTotal / data.totals.cost) : '—']);
out.push([]);
out.push(['LA REGLA · las cuatro a la vez']);
out.push(['1', 'Google lo sirvió como ' + tipos.map(servedLabel).join(', ') + '.']);
out.push(['2', 'Se ha gastado ' + umbral + moneda + ' o más (CONFIG.REVIEW_MIN_COST).']);
out.push(['3', 'NO lo estás comprando: su texto no es ninguna de tus '
+ diag.keywordsInventario + ' keywords activas.']);
out.push(['4', 'Cero conversiones en ' + CONFIG.DATE_RANGE + '.']);
out.push([]);
out.push(['ANTES DE NEGATIVIZAR NADA']);
out.push(['·', 'Un término sin conversión atribuida puede estar asistiendo: mira el modelo de '
+ 'atribución y la ventana de conversión antes de cortar.']);
out.push(['·', 'Si la columna «Solapa» dice SÍ, no tienes un término nuevo: tienes duplicidad. '
+ 'Ahí suele ser mejor ajustar la keyword amplia que negativizar el término.']);
out.push(['·', 'Negativiza en exacta si quieres cortar solo ese término, y en frase si quieres '
+ 'cortar la familia. No es lo mismo y no se deshace igual.']);
out.push([]);
var head = ['Término de búsqueda', 'Campaña', 'Grupo', 'Keyword que lo activó', 'Servido como',
'Clics', 'Coste', 'Conv.', 'Solapa', 'Observación'];
out.push(head);
for (var j = 0; j < candidatos.length; j++) {
var c = candidatos[j];
out.push([c.term, c.campaign, c.adGroup, c.keyword, servedLabel(c.served),
c.clicks, round(c.cost), 0, c.overlap ? 'SÍ' : '', reviewNote(c, moneda)]);
}
if (!candidatos.length) {
out.push(['Ningún término cumple las cuatro condiciones con el umbral actual ('
+ umbral + moneda + '). Baja CONFIG.REVIEW_MIN_COST si quieres mirar más abajo.']);
}
var width = head.length;
var headRow = out.length - candidatos.length - (candidatos.length ? 0 : 1);
sheet.getRange(1, 1, out.length, width).setValues(pad(out, width));
sheet.getRange(1, 1).setFontWeight('bold').setFontSize(13);
if (headRow >= 1) sheet.getRange(headRow, 1, 1, width).setFontWeight('bold');
return { n: candidatos.length, coste: round(costeTotal) };
}
/** La observación, escrita como una frase entera y no como un código. */
function reviewNote(r, moneda) {
var s = 'Se sirvió como ' + servedLabel(r.served) + ', se ha gastado ' + round(r.cost) + moneda
+ ', no la estás comprando como keyword y no ha convertido. Revísala: candidata a negativo.';
if (r.overlap) {
s += ' Además su texto ya lo cubre una keyword tuya en frase o exacta, así que probablemente '
+ 'sea duplicidad y no un término nuevo.';
}
if (r.configured === 'SIN KEYWORD') {
s += ' Ojo: este término no tiene keyword atribuible, así que puede venir de una campaña '
+ 'dinámica o automatizada y un negativo de keyword quizá no lo corte.';
}
return s;
}
/* --------------------------- PESTAÑA «N-GRAMAS» --------------------------- */
/**
* El patrón por palabras en vez del término suelto. Es donde se ven las fugas por
* tema: «barata», «gratis», «segunda mano», «opiniones».
*
* AVISO IMPORTANTE, y va escrito en la propia pestaña: los costes de esta tabla
* NO suman el total. Un término de cuatro palabras aporta su coste a varios
* gramos a la vez, así que la columna de coste está deliberadamente sobrecontada.
* Sirve para ORDENAR y comparar temas entre sí, no para sumar.
*/
function writeNgramsSheet(ss, data, diag) {
var sizes = CONFIG.NGRAM_SIZES || [1];
var maxRows = toNumber(CONFIG.NGRAM_MAX_ROWS) || 400;
var sheet = resetSheet(ss, 'N-gramas');
var out = [];
out.push(['N-GRAMAS · el patrón por palabras, no el término suelto']);
out.push(['Los costes de esta tabla NO suman el total: un término aporta su coste a todos los '
+ 'gramos que contiene. Sirve para ordenar y comparar temas, no para sumar.']);
out.push(['Los gramos de UNA palabra descartan artículos y preposiciones. Los de dos y tres no, '
+ 'porque ahí sí significan algo.']);
out.push([]);
var head = ['Palabras', 'Gramo', 'Términos', 'Clics', 'Coste', 'Conv.', 'CPA', 'Valor', 'ROAS'];
var recortes = [];
for (var s = 0; s < sizes.length; s++) {
var size = toNumber(sizes[s]);
if (size < 1) continue;
var grams = countNgrams(data.terms, size);
var keys = Object.keys(grams).sort(function (a, b) { return grams[b].cost - grams[a].cost; });
if (keys.length > maxRows) {
recortes.push('gramos de ' + size + ' palabra' + (size > 1 ? 's' : '') + ': ' + keys.length
+ ' distintos, muestro los ' + maxRows + ' de más coste');
keys = keys.slice(0, maxRows);
}
out.push([(size === 1 ? 'UNA PALABRA' : size + ' PALABRAS') + ' · ' + keys.length + ' filas']);
out.push(head);
for (var k = 0; k < keys.length; k++) {
var g = grams[keys[k]];
out.push([size, keys[k], g.rows, g.clicks, round(g.cost), round(g.conversions),
cpaOrShortSample(g), round(g.value), roasText(g)]);
}
out.push([]);
}
if (recortes.length) {
out.push(['RECORTADO', 'Para que la pestaña siga siendo legible he limitado las filas por '
+ 'tamaño de gramo (CONFIG.NGRAM_MAX_ROWS = ' + maxRows + '). ' + recortes.join(' · ')
+ '. Lo recortado es siempre lo de menos coste.']);
diag.avisos.push('En «N-gramas» he recortado filas: ' + recortes.join(' · ')
+ '. Sube CONFIG.NGRAM_MAX_ROWS si quieres verlas todas.');
}
diag.ngramas = sizes.join(', ') + ' palabras' + (recortes.length ? ' (con recorte)' : '');
var width = head.length;
sheet.getRange(1, 1, out.length, width).setValues(pad(out, width));
sheet.getRange(1, 1).setFontWeight('bold').setFontSize(13);
}
/** Cuenta gramos de «size» palabras. Un gramo repetido en el mismo término cuenta una vez. */
function countNgrams(terms, size) {
var map = {};
for (var i = 0; i < terms.length; i++) {
var r = terms[i];
var words = normalize(r.term).split(' ');
if (words.length < size) continue;
var seen = {};
for (var w = 0; w + size <= words.length; w++) {
var gram = words.slice(w, w + size).join(' ');
if (size === 1 && STOPWORDS.indexOf(gram) !== -1) continue;
if (seen[gram]) continue; // no contar dos veces el mismo término
seen[gram] = true;
if (!map[gram]) map[gram] = blankBucket();
add(map[gram], r);
}
}
return map;
}
/* --------------------------- PESTAÑA «HISTÓRICO» --------------------------- */
/**
* Una fila por ejecución. Es la única pestaña que el script NO borra: va
* acumulando, y por eso mismo es la que convierte una foto en una tendencia.
* Un informe te dice dónde estás; una serie te dice si vas a mejor o a peor.
*/
function appendHistoryRow(ss, data, diag) {
var head = ['Generado', 'Periodo', 'Mín. clics', 'Términos', 'Campañas'];
for (var t = 0; t < SUMMARY_ORDER.length; t++) {
var n = SUMMARY_ORDER[t];
head.push(n + ' coste', n + ' conv.', n + ' CPA', n + ' valor', n + ' ROAS');
}
head.push('Coste total campañas', 'Coste sin tipo de concordancia', 'Duración (min)');
var sheet = ss.getSheetByName('Histórico');
if (!sheet) {
sheet = ss.insertSheet('Histórico');
sheet.getRange(1, 1, 1, head.length).setValues([head]).setFontWeight('bold');
sheet.setFrozenRows(1);
}
var row = [diag.generado, CONFIG.DATE_RANGE, CONFIG.MIN_CLICKS, diag.filas, diag.campanas];
for (var i = 0; i < SUMMARY_ORDER.length; i++) {
var b = data.byGroup[SUMMARY_ORDER[i]] || blankBucket();
row.push(round(b.cost), round(b.conversions), cpaOrShortSample(b), round(b.value), roasText(b));
}
var fuera = 0;
for (var served in data.byServed) {
if (!data.byServed.hasOwnProperty(served)) continue;
if (SUMMED_TYPES.indexOf(served) === -1) fuera += data.byServed[served].cost;
}
row.push(round(safeNumber(searchCampaignCost)), round(fuera), elapsedMin());
var last = sheet.getLastRow();
sheet.getRange(last + 1, 1, 1, row.length).setValues([pad([row], head.length)[0]]);
diag.historico = 'fila ' + (last + 1) + ' añadida · ' + last + ' ejecuciones anteriores';
}
/* ----------------------------- PESTAÑA «PANEL» ----------------------------- */
/**
* Los gráficos. Se dibujan sobre una tabla que escribe el script en su propia
* pestaña, NO sobre el bloque de la plantilla: así no hay que adivinar dónde
* está el bloque y no se toca nada de lo que él calcula.
* Si la API de gráficos fallara, se queda la tabla y el script sigue.
*/
function writePanelSheet(ss, data, diag) {
var sheet = resetSheet(ss, 'Panel');
var out = [['PANEL · coste y CPA por concordancia'],
['Los mismos números que el bloque SUMARY, agrupados igual. Si no cuadran, manda el bloque.'],
[],
['Tipo', 'Coste', 'Conversiones', 'CPA', 'Valor', 'ROAS']];
for (var i = 0; i < SUMMARY_ORDER.length; i++) {
var b = data.byGroup[SUMMARY_ORDER[i]] || blankBucket();
// Coste, conversiones y valor van como número porque los usa el gráfico.
// El CPA y el ROAS van como TEXTO a propósito: si no hay muestra tienen que
// poder decir «muestra corta», y un 0 numérico ahí sería una mentira que
// encima se dibujaría en la barra.
out.push([SUMMARY_ORDER[i], round(b.cost), round(b.conversions),
cpaOrShortSample(b), round(b.value), roasText(b)]);
}
sheet.getRange(1, 1, out.length, 6).setValues(pad(out, 6));
sheet.getRange(1, 1).setFontWeight('bold').setFontSize(13);
sheet.getRange(4, 1, 1, 6).setFontWeight('bold');
var dataRow0 = 4; // fila de cabecera de la tabla
var lastRow = dataRow0 + SUMMARY_ORDER.length;
try {
var charts = sheet.getCharts();
for (var c = 0; c < charts.length; c++) sheet.removeChart(charts[c]);
var barras = sheet.newChart()
.setChartType(Charts.ChartType.COLUMN)
.addRange(sheet.getRange(dataRow0, 1, lastRow - dataRow0 + 1, 2))
.setPosition(dataRow0 + SUMMARY_ORDER.length + 2, 1, 0, 0)
.setOption('title', 'Coste por tipo de concordancia')
.setOption('legend', { position: 'none' })
.build();
sheet.insertChart(barras);
var hist = ss.getSheetByName('Histórico');
if (hist && hist.getLastRow() > 2) {
var linea = sheet.newChart()
.setChartType(Charts.ChartType.LINE)
.addRange(hist.getRange(1, 1, hist.getLastRow(), 1))
.addRange(hist.getRange(1, 6, hist.getLastRow(), 1))
.addRange(hist.getRange(1, 11, hist.getLastRow(), 1))
.addRange(hist.getRange(1, 16, hist.getLastRow(), 1))
.setPosition(dataRow0 + SUMMARY_ORDER.length + 22, 1, 0, 0)
.setOption('title', 'Coste por concordancia, ejecución a ejecución')
.build();
sheet.insertChart(linea);
diag.graficos = 'barras + línea del histórico';
} else {
diag.graficos = 'barras (la línea del histórico necesita al menos dos ejecuciones)';
}
} catch (e) {
diag.graficos = 'no se han podido dibujar: ' + (e && e.message ? e.message : e);
diag.avisos.push('Los gráficos del Panel no se han podido dibujar, pero la tabla con los '
+ 'números sí está. Motivo: ' + (e && e.message ? e.message : e));
}
}
/* ------------------------------- EL CORREO ------------------------------- */
/**
* El resumen en tres líneas. Nadie abre una hoja de cálculo por iniciativa
* propia; un correo sí se lee. Con EMAIL_ONLY_IF_BROAD_WORSE_PCT solo escribe
* cuando hay algo que contar, que es la única forma de que no se ignore.
*/
function sendSummaryEmail(ss, data, diag) {
if (!CONFIG.EMAIL_TO) return;
var ex = data.byGroup.EXACT || blankBucket();
var br = data.byGroup.BROAD || blankBucket();
var cpaEx = ex.conversions > 0 ? ex.cost / ex.conversions : null;
var cpaBr = br.conversions > 0 ? br.cost / br.conversions : null;
var peorPct = (cpaEx && cpaBr) ? ((cpaBr / cpaEx) - 1) * 100 : null;
var umbral = CONFIG.EMAIL_ONLY_IF_BROAD_WORSE_PCT;
if (umbral !== null && umbral !== undefined) {
if (peorPct === null) {
diag.correo = 'no enviado: sin muestra para comparar el CPA de amplia con el de exacta';
return;
}
if (peorPct < toNumber(umbral)) {
diag.correo = 'no enviado: amplia está ' + Math.round(peorPct) + ' % sobre exacta y el umbral '
+ 'es ' + umbral + ' %';
return;
}
}
var moneda = diag.moneda === 'no disponible' ? '' : ' ' + diag.moneda;
var lineas = [];
lineas.push('Cuenta: ' + diag.cuenta + ' · periodo ' + CONFIG.DATE_RANGE);
lineas.push('Amplia: ' + round(br.cost) + moneda + ' y ' + round(br.conversions)
+ ' conversiones (CPA ' + cpaOrShortSample(br) + ').');
lineas.push('Exacta: ' + round(ex.cost) + moneda + ' y ' + round(ex.conversions)
+ ' conversiones (CPA ' + cpaOrShortSample(ex) + ').');
if (peorPct !== null) {
lineas.push('El CPA de amplia está un ' + Math.round(peorPct) + ' % '
+ (peorPct >= 0 ? 'POR ENCIMA' : 'por debajo') + ' del de exacta.');
}
if (diag.revisar) {
lineas.push('Términos a revisar: ' + diag.revisar.n + ', que suman ' + diag.revisar.coste
+ moneda + '. Están en la pestaña «Revisar» — son candidatos, no órdenes.');
}
if (diag.omitidas.length) {
lineas.push('AVISO: se agotó el presupuesto de tiempo y quedó fuera: ' + diag.omitidas.join(', ')
+ '. Mira la pestaña Diagnóstico.');
}
lineas.push('');
lineas.push(ss.getUrl());
try {
MailApp.sendEmail({
to: CONFIG.EMAIL_TO,
subject: 'Concordancia amplia · ' + diag.cuenta + ' · ' + diag.generado,
body: lineas.join('\n')
});
diag.correo = 'enviado a ' + CONFIG.EMAIL_TO;
} catch (e) {
diag.correo = 'ha fallado el envío: ' + (e && e.message ? e.message : e);
diag.avisos.push('No he podido enviar el correo a ' + CONFIG.EMAIL_TO + ': '
+ (e && e.message ? e.message : e) + '. El informe está escrito igualmente.');
}
}
/** Detalle término a término con el cruce de tipos y el solapamiento. */
function writeTermsSheet(ss, data) {
var sheet = resetSheet(ss, 'Términos');
var head = ['Campaña', 'Grupo', 'Término de búsqueda', 'Keyword que lo activó',
'Configurado por ti', 'Servido por Google', 'Expansión',
'¿La compras como keyword?', 'Solapa con frase/exacta',
'Impr.', 'Clics', 'Coste', 'Conv.', 'Valor', 'Estado'];
var out = [head];
var rows = data.terms.slice().sort(function (a, b) { return b.cost - a.cost; });
for (var i = 0; i < rows.length; i++) {
var r = rows[i];
out.push([r.campaign, r.adGroup, r.term, r.keyword, label(r.configured), servedLabel(r.served),
expansionFlag(r.configured, r.served),
r.owned ? 'sí, en ' + label(r.ownedAs).toLowerCase() : 'no',
r.overlap ? 'SÍ' : '',
r.impressions, r.clicks, round(r.cost), round(r.conversions), round(r.value), r.status]);
}
sheet.getRange(1, 1, out.length, head.length).setValues(pad(out, head.length));
sheet.getRange(1, 1, 1, head.length).setFontWeight('bold');
sheet.setFrozenRows(1);
}
/** La lectura clave: qué hizo Google frente a lo que configuraste. */
function expansionFlag(configured, served) {
if (configured === 'SIN KEYWORD') return 'sin keyword atribuible';
if (configured === 'EXACT' && served === 'NEAR_EXACT') return 'variante aproximada de exacta';
if (configured === 'PHRASE' && served === 'NEAR_PHRASE') return 'variante aproximada de frase';
if (configured === 'BROAD' && served === 'BROAD') return 'expansión semántica pura';
if (configured === 'BROAD') return 'amplia servida como ' + String(served).toLowerCase();
return 'coincide con lo configurado';
}
/** Qué ha pasado en la ejecución. Se escribe siempre, también cuando algo falla. */
function writeDiagnosticsSheet(ss, diag, data) {
var sheet = resetSheet(ss, 'Diagnóstico');
var out = [
['Impacto real de la concordancia amplia · diagnóstico de la ejecución'],
[],
['Cuenta', diag.cuenta],
['Generado', diag.generado],
['Periodo consultado', diag.periodo],
['Umbral mínimo de clics', diag.minClics],
['Moneda', diag.moneda],
[],
['Términos leídos', diag.filas],
['Campañas con datos', diag.campanas],
['Hoja modelo', diag.modelo || 'no encontrada'],
['Hojas rellenadas', diag.hojasEscritas],
['Hojas creadas duplicando la modelo', diag.hojasCreadas],
['Hojas rehechas por no tener el bloque', diag.hojasRehechas],
['Fórmulas SUMIF ampliadas', diag.rangosAmpliados],
['Texto de la keyword disponible', diag.keywordText],
['Keywords activas en el inventario', diag.keywordsInventario || 'no consultado'],
[],
['TIEMPO · el script para antes de que Google lo corte'],
['Duración de la ejecución', diag.duracion + ' min'],
['Presupuesto (TIME_BUDGET_MIN)', CONFIG.TIME_BUDGET_MIN + ' min'],
['Límite duro de Google', '30 min · si lo alcanza, corta sin guardar nada'],
['Etapas omitidas', diag.omitidas.length ? diag.omitidas.join(', ') : 'ninguna'],
['Reparto por etapa', stageBreakdown()],
[],
['EXTRAS DE ESTA EJECUCIÓN'],
['Términos a revisar', diag.revisar
? diag.revisar.n + ' términos · ' + diag.revisar.coste + ' de coste'
: 'no generado'],
['N-gramas', diag.ngramas || 'no generado'],
['Histórico', diag.historico || 'no generado'],
['Gráficos', diag.graficos || 'no generado'],
['Correo', diag.correo],
['Muestra mínima para dar un CPA', CONFIG.MIN_CONVERSIONS_FOR_CPA + ' conversiones']
];
if (data) {
out.push([]);
out.push(['REPARTO POR TIPO SERVIDO · lo que suma cada fila del SUMARY']);
out.push(['Etiqueta en la columna D', 'Coste']);
for (var served in data.byServed) {
if (!data.byServed.hasOwnProperty(served)) continue;
out.push([servedLabel(served) + (SUMMED_TYPES.indexOf(served) === -1 ? ' ← no lo suma el SUMARY' : ''),
round(data.byServed[served].cost)]);
}
out.push([]);
out.push(['COBERTURA · el aviso que hay que declarar en cualquier informe']);
out.push(['El informe de términos solo incluye términos con volumen suficiente.']);
out.push(['Coste atribuido a términos visibles', round(data.totals.cost)]);
out.push(['Coste total de las campañas de búsqueda', round(safeNumber(searchCampaignCost))]);
out.push(['La diferencia entre esas dos líneas es tu punto ciego. Cualquier % de aquí es un SUELO.']);
}
if (diag.avisos.length) {
out.push([]);
out.push(['AVISOS']);
for (var i = 0; i < diag.avisos.length; i++) out.push([String(i + 1), diag.avisos[i]]);
}
sheet.getRange(1, 1, out.length, 2).setValues(pad(out, 2));
sheet.getRange(1, 1).setFontWeight('bold').setFontSize(13);
}
/* ------------------------------- 7 · UTILIDADES ------------------------------- */
function openSpreadsheet() {
if (!CONFIG.SPREADSHEET_URL || CONFIG.SPREADSHEET_URL.indexOf('docs.google.com') === -1) {
throw new Error('Falta la URL de tu copia de la hoja de cálculo en CONFIG.SPREADSHEET_URL. '
+ 'Haz Archivo → Hacer una copia y pega la URL de TU copia.');
}
return SpreadsheetApp.openByUrl(CONFIG.SPREADSHEET_URL);
}
/** Solo para las pestañas que crea el script. Nunca se llama sobre la modelo. */
function resetSheet(ss, name) {
var sheet = ss.getSheetByName(name);
if (sheet) { sheet.clear(); sheet.setFrozenRows(0); return sheet; }
return ss.insertSheet(name);
}
/** Coste total de las campañas de búsqueda, para medir el punto ciego. */
function searchCampaignCost() {
var q = 'SELECT metrics.cost_micros FROM campaign '
+ 'WHERE segments.date DURING ' + CONFIG.DATE_RANGE
+ ' AND campaign.advertising_channel_type = "SEARCH"';
var total = 0;
var it = AdsApp.search(q);
while (it.hasNext()) total += toNumber(it.next().metrics.costMicros) / 1000000;
return total;
}
function label(type) {
return { EXACT: 'Exacta', PHRASE: 'Frase', BROAD: 'Amplia' }[type] || type;
}
/** Minúsculas y espacios normalizados. No quita tildes: en español cambian el término. */
function normalize(s) {
return String(s || '').toLowerCase().replace(/\s+/g, ' ').trim();
}
function containsAny(haystack, needles) {
for (var i = 0; i < needles.length; i++) {
if (needles[i] && haystack.indexOf(needles[i]) !== -1) return true;
}
return false;
}
function toNumber(v) { var n = Number(v); return isNaN(n) ? 0 : n; }
function round(n) { return Math.round(Number(n) * 100) / 100; }
function pct(x) { return Math.round(x * 1000) / 10 + ' %'; }
/**
* El CPA, o «muestra corta» si no hay conversiones suficientes para que la cifra
* signifique algo. Un CPA sobre 2 conversiones es ruido con decimales, y darlo
* con dos decimales es peor que no darlo: parece un dato.
*/
function cpaOrShortSample(b) {
var minimo = toNumber(CONFIG.MIN_CONVERSIONS_FOR_CPA);
if (!b.conversions) return '—';
if (b.conversions < minimo) {
return 'muestra corta (' + round(b.conversions) + ' conv.)';
}
return round(b.cost / b.conversions);
}
/** ROAS como porcentaje, igual que en la calculadora de Webble. */
function roasText(b) {
if (!b.cost) return '—';
if (!b.value) return '0 %';
return pct(b.value / b.cost);
}
/** Envuelve una consulta que puede fallar sin que se caiga la ejecución entera. */
function safeNumber(fn) {
try { return toNumber(fn()); } catch (e) { return 0; }
}
/** Iguala el ancho de todas las filas para que setValues() no falle. */
function pad(rows, width) {
for (var i = 0; i < rows.length; i++) {
while (rows[i].length < width) rows[i].push('');
}
return rows;
}
Si encuentras otro script que usa SEARCH_QUERY_PERFORMANCE_REPORT o KeywordMatchType en la consulta, no lo copies: es sintaxis antigua. El equivalente de hoy es FROM search_term_view con segments.keyword.info.match_type, y esa consulta la publica Google en su propio recetario.
Cambios y versiones
| Versión | Fecha | Qué cambia |
|---|---|---|
| 4.0 | Agosto 2026 | Pestaña Revisar con los términos que cumplen las cuatro condiciones, N-gramas, Histórico acumulativo y Panel con gráficos. Valor y ROAS en el bloque, y CPA que dice «muestra corta» en lugar de inventarse precisión. Aviso por correo con umbral opcional. Y presupuesto de tiempo propio para no morir en el límite de 30 minutos de Google: escribe primero el informe y declara lo que omite. |
| 3.1 | Agosto 2026 | Columna Keyword en el volcado, delante del término: la que Google trae desactivada por defecto. Y la cabecera se lee por título en vez de por letra de columna, así que reordenar o renombrar columnas ya no rompe nada. |
| 3.0 | Agosto 2026 | El script vuelve a escribir en la plantilla: vuelca los términos en la tabla de la hoja modelo y deja que el bloque SUMARY se calcule solo con sus SUMIF. No toca fórmulas ni formato. La hoja modelo recibe el total de la cuenta y cada campaña hereda el bloque duplicándola. Amplía los rangos de los SUMIF cuando el volcado se pasa de largo. Pestañas «Términos» y «Diagnóstico» aparte. |
| 2.1 | Agosto 2026 | Diagnóstico que se escribe siempre, para que la hoja nunca aparezca en blanco, y reintento de la consulta sin segments.keyword.info.text si la API lo rechaza. |
| 2.0 | Agosto 2026 | Migración a GAQL. Cruce entre tipo configurado y tipo servido, columna de expansión, detección de solapamiento con frase y exacta, fila de tráfico sin keyword y cálculo del punto ciego de cobertura. |
| 1.x | 2023–2024 | Las primeras versiones, publicadas en LinkedIn. Consultaban el informe con la sintaxis antigua y no cruzaban el tipo servido. |
Preguntas frecuentes
Cruza los dos campos de tipo de concordancia —el que configuraste y el que Google usó— y aísla lo que solo pudo entrar por amplia.
Copias la hoja, pegas su URL en SPREADSHEET_URL y pegas el código en Herramientas y ajustes → Acciones masivas → Scripts. Ejecútalo primero en modo vista previa.
No puede tocar nada: no invoca mutate, createLabel ni nada que pause, elimine o cambie pujas. Solo lee informes y escribe en tu hoja. Y el código está a la vista, así que no tienes que creerme.
El bloque se calcula con SUMIF sobre la tabla de la propia hoja, así que si está a cero es que la tabla no se ha volcado. Abre la pestaña Diagnóstico: ahí verás cuántos términos se han leído, qué hoja ha detectado como modelo y qué ha fallado. Lo más habitual es el periodo, el umbral de clics, o que la cuenta no tenga campañas de búsqueda con gasto.
Si el volcado sí está pero el SUMARY no suma, comprueba la columna del tipo de concordancia: los SUMIF buscan los textos exactos de la interfaz de Google —«Exact», «exact (close variant)», «phrase», «phrase (close variant)» y «broad»—. Cualquier otra palabra ahí y la suma da cero.
Empieza por Revisar: te da la cifra en euros de lo que se está gastando en términos que no compras y que no convierten. Contrástalo con los n-gramas antes de negativizar nada, porque a veces no sobra un término sino un tema entero. Si ese gasto supera lo que asumirías como exploración, el problema rara vez está solo ahí: auditamos tu cuenta entera, no solo la concordancia.
- Columnas «Palabra clave» y «Tipo de concordancia» del informe de términos de búsqueda, y el hecho de que la primera venga oculta por defecto: Google Ads, ayuda oficial del informe de términos de búsqueda.
- Consulta con keyword activadora y tipo de concordancia: Google Ads API, recetario oficial de consultas GAQL. Enum
SearchTermMatchTypeconAI_MAXyPERFORMANCE_MAX: referencia de la API, versión 21. - Límite de 30 minutos de ejecución y exención de los informes del límite de entidades: Google Ads Scripts, documentación oficial de límites.
- Gasto sin término de búsqueda visible (hasta el 29,9 %): Lunio, más de 60 cuentas bajo una cuenta de administrador, comparativa de agosto y septiembre de 2020.
Pero la concordancia casi nunca es el único agujero. Revisamos estructura, pujas, medición y creatividades, y te decimos qué arreglar primero.
