Skript na hlídání výkyvů PPC kampaní (v Google Ads)

Ačkoli dobře nastavené kampaně mohou běžet celé měsíce bez jediné úpravy, občas stačí jediný den a jediná drobná chybka, aby se kampaň vydala špatným směrem a způsobila vám pořádnou ztrátu. Pravidelná kontrola kampaní je proto opravdu důležitá, zvláště pokud spravujete více účtů s desítkami, možná stovkami aktivních kampaní. A ačkoliv je lidský dohled nenahraditelný, je možné jej do určité míry automatizovat. Proto vznikl tento skript.

Je napsaný tak, aby pravidelně procházel kampaně a v případě předem definované anomálie zaslal upozornění na e-mail.

TL; DR

Tady se můžete prokliknout na samotný skript a instrukce k nasazení.

Jak to přesně funguje?

Skript pravidelně prochází kampaně a sleduje Cenu, CTR a CPA za dané období a srovnává tyto metriky s obdobím předcházejícím. Kontrola probíhá pro 1 den (včerejšek), posledních 7 a posledních 28 dní. Pokud rozdíl mezi srovnávanými časovými úseky přesáhne koeficient, který si nastavíte při implementaci, zašle vám skript upozornění na e-mail.

Praktické využití

Důvodem pro tvorbu tohoto skriptu pro mě byla potřeba rychle reagovat na nečekané situace a výkyvy v reklamních Google Ads účtech. Např. když se kampaň kvůli nějaké technické chybě zastaví nebo se naopak „zblázní“ a začne utrácet výrazně víc peněz. Případně když přestane fungovat měření konverzí nebo dojde k nějaké vnější změně, která dramaticky změní nákupní chování zákazníků.

V ideálním případě tedy žádné upozornění nedostanete a naopak, když vám e-mail přijde, víte, že je něco v nepořádku a je třeba urychleně jednat.

Prostým nastavením koeficientů můžete přizpůsobit fungování skriptu vlastním potřebám. Pokud si skript nastavíte na hlídání jemnějších změn, můžete jej využít jako denní přehled vývoje kampaní, případně sledovat jen týdenní, či měsíční výkyvy…

Jak skript vznikal

Následující řádky popisují samotnou tvorbu skriptu a překážky, na které jsem během tvorby narazil. Zajímat vás budou, pokud sami skripty tvoříte, nebo si jen chcete přečíst dramatický příběh 🙂 V opačném případě můžete s klidem přeskočit na další část.

Abych nevymýšlel kolo, pustil jsem se do hledání skriptu, který by dělal něco podobného. Nejblíže mým potřebám byl skript Nilse Rooijmanse Daily budget overdelivery alert. Ten sice hlídal pouze cenu a srovnával ji ne s předchozím obdobím, ale ručně nastaveným budgetem, ale princip byl podobný.

Z tohoto skriptu nakonec nezůstala zachovaná ani řádka.

Největším problémem byl fakt, že skript využíval pro získávání statistik funkci:

AdWordsApp.campaigns()

Ta však získává statistiky pouze pro vyhledávací a obsahové kampaně. Pro shopping kampaně můžete sice využít metodu

.shoppingCampaigns()

Ale i ta stáhne data pouze pro klasické shopping kampaně. Stáhnout podobným způsobem data ze smart shopping kampaní možné není.

Tohle všechno jsem se dozvídal metodou pokusů a omylů, měnil parametry skriptu a žasnul, proč se mi upozornění pro některé kampaně zasílá, zatímco jiné jsou vůči kontrole naprosto netečné.

Zdálo se, že Google Ads script nemá funkci, která by podobnou operaci umožňovala. Ale přesto jsem věděl, že už dlouho používám skript, který mi stahuje data z kampaní pro můj přehled v Google sheetech. Tím kouzelným nástrojem byla metoda:

.report()

Narozdíl od metody „campaigns()” neumožňuje provádět úpravy v kampaních, ale to jsem v tu chvíli nepotřeboval, jediné co mě zajímalo bylo stažení dat z reklamního účtu. Pomocí AWQL jsem tedy sestavil dotaz, který mi potřebná data na úrovni kampaní stáhnul a mohl jsem se pustit do práce.

S drobnou, ale neocenitelnou pomocí facebookového dobrodince jménem Kurvin Guiste jsem data z reportu zpracoval a napsal si funkci, která vracela tu správnou hodnotu pro danou kampaň, metriku a časové období.

Ale ukázalo se, že jde o příliš pomalé a nepříliš funkční řešení. Zatímco pomocí metody „campaign()” a dalších navazujících metod si ze systému vytáhnete přesně to, co potřebujete, „report()” i při nastavení všech možných filtrů stahuje celkem velký a komplexní report, který pak dál zpracováváte. A když ve finále porovnáváte pro každou kampaň 18 různých metrik, je to pro systém zátěž, která často vede k selhání skriptu (zvlášť pokud to děláte pro více účtů).

Zvolil jsem tedy jiný přístup. Report vygeneruji pouze jednou (no dobře, technicky vzato je to 6× kvůli různým časovým obdobím) na samotném začátku a všechny potřebné metriky si uložím do jediného JSON objektu, odkud je pak jednoduše vytáhnu ve chvíli, kdy je potřebuji.

Dalším problémem je pak formát výstupu, který je v podobě textového řetězce včetně znaků pro procenta, oddělovačů tisíců a dalšího nepořádku, který je potřeba očistit a převést na číslo.

Tím byly ty nejzásadnější technické problémy odstraněny a zbývalo už jen porovnat několik čísel a výsledek odeslat do mailu.

Vyskytl se ale problém řekněme „analytický”. Některé kampaně vyvolávaly poplach téměř neustále a skutečné problémy se pak mezi nimi ztrácely. Třeba maličká kampaň, která denně utratí kolem 10 korun a přivede 3 kliknutí. Stačí aby jeden den utratila 2 koruny a přivedla jeden klik a cena se sníží o 80 %, zatímco CTR vyletí klidně o 500 %.

Vznikly tak kontrolní mechanismy. Tím prvním je hranice, od které vás kampaň varuje, že došlo ke změně. Musí jít o navýšení o 20 %, nebo nás zajímá až pětinásobný nárůst? To je teď ve vašich rukou. Nastavit si také můžete, co chcete kontrolovat za jaké časové období. Chcete zkontrolovat jak se od včera změnilo CPA nebo vám stačí srovnání mezitýdenní, případně meziměsíční?

Největší průlom však přineslo nastavení minimální cenové hranice pro CTR a CPA alerty. Malé kampaně s jednotkami kliknutí mají v řádu dnů výkyvy v CTR stovky i tisíce procent. Pokud však řeknete, že chcete kontrolovat jen kampaně, které v daném období utratily minimálně 500 Kč, všechny tyhle odchylky rázem odfiltrujete, protože v dlouhodobějším horizontu získáte celkem stabilní CTR i u kampaně, která je na denní bázi velmi „divoká”.

A poslední korekcí byl „problém nuly”. Pokud jste kampaň právě spustili, každá ze sledovaných metrik se oproti předchozímu období nekonečně zvýší. Zavedl jsem proto podmínku, že pokud je daná metrika za předchozí období rovna 0, skript takové porovnání přeskočí a upozornění nezašle. V naprosté většině případů je to pro dobro věci, ale raději to zmiňuji pro případ, že byste narazili na nějakou výjimečnou situaci, kdy byste takové srovnání potřebovali.

Poděkování

A než se pustíme do samotného nastavení, chtěl bych poděkovat několika lidem.

Haně Kobzové za cenné připomínky a seznámení s mnoha zásadními funkcemi, které jsem neznal nebo by mě nenapadlo je použít.

Lynt.cz za jejich skvělý generátor AWQL dotazů, z něhož jsem si vypůjčil funkce na formátování času a korekci letního času.

Kurvinu Guisteovi za tip jak zpracovat výstup z metody .report().

Jirkovi Langerovi za tipy a opravu syntaktických chybek.

Nastavení a kalibrace skriptu

Tady je skript v celé své kráse. Otevřete si Google Ads, přejděte do menu Nástroje a nastavení > Skripty, přidejte nový skript, smažte předvyplněné údaje a celý skript do políčka vložte. Nezapomeňte po nastavení povolit všechna povolení a přístupy, někdy to je trochu detektivní práce, protože dialogové okno se vás od tohoto cíle bude snažit odradit 🙂

/**
* @overview:
* For each campaign that is NOT labeled 'noCampaignAlerts' this script will compare the Spend, CTR and CPA metric with a previous time period.
* In case of big differences (set up by user) it will log an alert and sends an email notification
* Created by Ladislav Vitouš, 2022
* First published on https://www.vitousladislav.cz/
* For support or MMC version write to info@vitousladislav.cz
*/
// Config basic informations
var EMAIL = "set@youremail.com"; //insert your email
var CAMPAIGNLABEL = "noCampaignAlerts"; //campaign level label for campaigns to ignore
// Treshold for over/under delivery of specific metric. For example 2 for spend 2 times higher 0.5 for half the spend
var adSpendOverDelivery = 2; // alert if the spend overdelivery is greater than x times higher than in previous period
var adSpendUnderDelivery = 0.2; // alert if the spend underdelivery is lower than 0.x times the previous value
var cpaOverDelivery = 2.75; // the same for Cost per Conversion
var cpaUnderDelivery = 0.05; // the same for Cost per Conversion
var ctrOverDelivery = 5; // CTR
var ctrUnderDelivery = 0.5; // CTR
var minimalSpend = 200; // Set default minimal spend for checking CPA and CTR metric. If spend for given time range is lower, script won't send an alert for CPA and CTR. If account has a label "Control_xxx", xxx will be used instead.
// Config what metrics and time periods you want to check. Set false for skipping it
var checkDaily = true; // compare yesterday's results with the day before yesterday results
var checkWeekly = true; // compare last 7 days with days 14 to 8
var checkMonthly = true; // compare last 28 days with previous 28 days (56 to 29)
var checkDailyAdSpend = true; // Daily comparison of ad spend
var checkDailyCpa = true; // Daily comparison of CPA
var checkDailyCtr = true; // Daily comparison of CTR
var checkWeeklyAdSpend = true; // Ad spend last 7 days
var checkWeeklyCpa = true;
var checkWeeklyCtr = true;
var checkMonthlyAdSpend = true; // Ad spend last 28 days
var checkMonthlyCpa = true;
var checkMonthlyCtr = true;
function main() {
var campaignAlert = false;
Logger.log("Checking account: " + AdsApp.currentAccount().getName());
Logger.log("Minimal spend: " + minimalSpend);
// if not already created, create label used to Ignore campaigns
// this is necessary for the script to run even if no campaign has been labeled
var labelName;
var labelExists = false;
var labelIterator = AdsApp.labels().get();
while (labelIterator.hasNext()) {
labelName = labelIterator.next().getName();
if (labelName.localeCompare(CAMPAIGNLABEL) == 0) {
labelExists = true;
break;
} else {
labelExists = false;
}
}
if (labelExists == false) {
AdsApp.createLabel(CAMPAIGNLABEL);
Logger.log("labelCreated");
} else {
Logger.log("labelExists");
}
if (AdsApp.labels().withCondition("Name = " + CAMPAIGNLABEL).get().hasNext()) {
var labelId = AdsApp.labels().withCondition("Name = " + CAMPAIGNLABEL).get().next().getId();
}
// Download all the data from Ads report and create an array of campaign metrics
var listOfCampaigns = [];
if (checkDaily) {
var to = lynt_get_date(1, 1);
var from = lynt_get_date(1, 1);
var report = AdsApp.report("SELECT CampaignName, Cost, CostPerConversion, Ctr FROM CAMPAIGN_PERFORMANCE_REPORT WHERE CampaignStatus = ENABLED AND Labels CONTAINS_NONE ['" + labelId + "'] DURING " + from + "," + to + "");
var rows = report.rows();
while (rows.hasNext()) {
var oneCampaign = rows.next();
var campaign = {
name: oneCampaign.CampaignName,
cost: oneCampaign.Cost,
ctr: oneCampaign.Ctr,
cpa: oneCampaign.CostPerConversion,
timeframe: '1to1'
};
listOfCampaigns.push(campaign);
}
var to = lynt_get_date(2, 1);
var from = lynt_get_date(2, 1);
var report = AdsApp.report("SELECT CampaignName, Cost, CostPerConversion, Ctr FROM CAMPAIGN_PERFORMANCE_REPORT WHERE CampaignStatus = ENABLED AND Labels CONTAINS_NONE ['" + labelId + "'] DURING " + from + "," + to + "");
var rows = report.rows();
while (rows.hasNext()) {
var oneCampaign = rows.next();
var campaign = {
name: oneCampaign.CampaignName,
cost: oneCampaign.Cost,
ctr: oneCampaign.Ctr,
cpa: oneCampaign.CostPerConversion,
timeframe: '2to2'
};
listOfCampaigns.push(campaign);
}
}
if (checkWeekly) {
var to = lynt_get_date(1, 1);
var from = lynt_get_date(7, 1);
var report = AdsApp.report("SELECT CampaignName, Cost, CostPerConversion, Ctr FROM CAMPAIGN_PERFORMANCE_REPORT WHERE CampaignStatus = ENABLED AND Labels CONTAINS_NONE ['" + labelId + "'] DURING " + from + "," + to + "");
var rows = report.rows();
while (rows.hasNext()) {
var oneCampaign = rows.next();
var campaign = {
name: oneCampaign.CampaignName,
cost: oneCampaign.Cost,
ctr: oneCampaign.Ctr,
cpa: oneCampaign.CostPerConversion,
timeframe: '7to1'
};
listOfCampaigns.push(campaign);
}
var to = lynt_get_date(8, 1);
var from = lynt_get_date(14, 1);
var report = AdsApp.report("SELECT CampaignName, Cost, CostPerConversion, Ctr FROM CAMPAIGN_PERFORMANCE_REPORT WHERE CampaignStatus = ENABLED AND Labels CONTAINS_NONE ['" + labelId + "'] DURING " + from + "," + to + "");
var rows = report.rows();
while (rows.hasNext()) {
var oneCampaign = rows.next();
var campaign = {
name: oneCampaign.CampaignName,
cost: oneCampaign.Cost,
ctr: oneCampaign.Ctr,
cpa: oneCampaign.CostPerConversion,
timeframe: '14to8'
};
listOfCampaigns.push(campaign);
}
}
if (checkMonthly) {
var to = lynt_get_date(1, 1);
var from = lynt_get_date(28, 1);
var report = AdsApp.report("SELECT CampaignName, Cost, CostPerConversion, Ctr FROM CAMPAIGN_PERFORMANCE_REPORT WHERE CampaignStatus = ENABLED AND Labels CONTAINS_NONE ['" + labelId + "'] DURING " + from + "," + to + "");
var rows = report.rows();
while (rows.hasNext()) {
var oneCampaign = rows.next();
var campaign = {
name: oneCampaign.CampaignName,
cost: oneCampaign.Cost,
ctr: oneCampaign.Ctr,
cpa: oneCampaign.CostPerConversion,
timeframe: '28to1'
};
listOfCampaigns.push(campaign);
}
var to = lynt_get_date(29, 1);
var from = lynt_get_date(56, 1);
var report = AdsApp.report("SELECT CampaignName, Cost, CostPerConversion, Ctr FROM CAMPAIGN_PERFORMANCE_REPORT WHERE CampaignStatus = ENABLED AND Labels CONTAINS_NONE ['" + labelId + "'] DURING " + from + "," + to + "");
var rows = report.rows();
while (rows.hasNext()) {
var oneCampaign = rows.next();
var campaign = {
name: oneCampaign.CampaignName,
cost: oneCampaign.Cost,
ctr: oneCampaign.Ctr,
cpa: oneCampaign.CostPerConversion,
timeframe: '56to29'
};
listOfCampaigns.push(campaign);
}
}
// Let's check the campaigns for anomalities
var campaignSelector = AdsApp.report("SELECT CampaignName FROM CAMPAIGN_PERFORMANCE_REPORT WHERE CampaignStatus = ENABLED AND Labels CONTAINS_NONE ['" + labelId + "'] DURING YESTERDAY");
var normalCampaignIterator = campaignSelector.rows();
var campaignErrors = [];
CheckCampaigns(normalCampaignIterator);
// THE function that does all the heavy lifting
function CheckCampaigns(campaignIterator) {
while (campaignIterator.hasNext()) {
var campaign = campaignIterator.next();
var nameOfCampaign = campaign.CampaignName;
Logger.log("	Checking Campaign: " + nameOfCampaign);
// Creating variables for previous period for later comparison
if (checkDaily) {
var anticipatedDailyAdSpend = findAMetric(nameOfCampaign, '2to2', 'cost');
var anticipatedDailyCpa = findAMetric(nameOfCampaign, '2to2', 'cpa');
var anticipatedDailyCtr = findAMetric(nameOfCampaign, '2to2', 'ctr');
var anticipatedDailyAdSpend = anticipatedDailyAdSpend.replace(/,|%/g, "");
var anticipatedDailyCpa = anticipatedDailyCpa.replace(/,|%/g, "");
var anticipatedDailyCtr = anticipatedDailyCtr.replace(/,/g, "");
}
if (checkWeekly) {
var anticipatedWeeklyAdSpend = findAMetric(nameOfCampaign, '14to8', 'cost');
var anticipatedWeeklyCpa = findAMetric(nameOfCampaign, '14to8', 'cpa');
var anticipatedWeeklyCtr = findAMetric(nameOfCampaign, '14to8', 'ctr');
var anticipatedWeeklyAdSpend = anticipatedWeeklyAdSpend.replace(/,|%/g, "");
var anticipatedWeeklyCpa = anticipatedWeeklyCpa.replace(/,|%/g, "");
var anticipatedWeeklyCtr = anticipatedWeeklyCtr.replace(/,|%/g, "");
}
if (checkMonthly) {
var anticipatedMonthlyAdSpend = findAMetric(nameOfCampaign, '56to29', 'cost');
var anticipatedMonthlyCpa = findAMetric(nameOfCampaign, '56to29', 'cpa');
var anticipatedMonthlyCtr = findAMetric(nameOfCampaign, '56to29', 'ctr');
var anticipatedMonthlyAdSpend = anticipatedMonthlyAdSpend.replace(/,|%/g, "");
var anticipatedMonthlyCpa = anticipatedMonthlyCpa.replace(/,|%/g, "");
var anticipatedMonthlyCtr = anticipatedMonthlyCtr.replace(/,|%/g, "");
}
// daily check
if (checkDaily) {
var adSpendYesterday = findAMetric(nameOfCampaign, "1to1", "cost");
var adSpendYesterday = adSpendYesterday.replace(/,|%/g, "");
if (checkDailyAdSpend) {
if (adSpendYesterday > (anticipatedDailyAdSpend * adSpendOverDelivery) && anticipatedDailyAdSpend != 0) {
campaignAlert = true;
Logger.log("		DAILY overdelivery spend alert");
campaignErrors.push("<tr><td><strong>" + nameOfCampaign + "</strong></td><td>Daily overspend</td><td>" + adSpendYesterday + "</td><td>" + (adSpendYesterday / anticipatedDailyAdSpend).toFixed(2) * 100 + "%</td><td> Spent: " + findAMetric(nameOfCampaign, "1to1", "cost") + "</td></tr>");
}
if (adSpendYesterday < (anticipatedDailyAdSpend * adSpendUnderDelivery) && anticipatedDailyAdSpend != 0) {
campaignAlert = true;
Logger.log("		DAILY underdelivery spend alert");
campaignErrors.push("<tr><td><strong>" + nameOfCampaign + "</strong></td><td>Daily underspend</td><td>" + adSpendYesterday + "</td><td>" + (adSpendYesterday / anticipatedDailyAdSpend).toFixed(2) * 100 + "%</td><td> Spent: " + findAMetric(nameOfCampaign, "1to1", "cost") + "</td></tr>");
}
}
if (checkDailyCpa && adSpendYesterday > minimalSpend) {
var cpaYesterday = findAMetric(nameOfCampaign, "1to1", "cpa");
var cpaYesterday = cpaYesterday.replace(/,|%/g, "");
if (cpaYesterday > (anticipatedDailyCpa * cpaOverDelivery) && anticipatedDailyCpa != 0) {
campaignAlert = true;
Logger.log("		DAILY overdelivery CPA alert");
campaignErrors.push("<tr><td><strong>" + nameOfCampaign + "</strong></td><td>Daily overCPA</td><td>" + cpaYesterday + "</td><td>" + (cpaYesterday / anticipatedDailyCpa).toFixed(2) * 100 + "%</td><td> Spent: " + findAMetric(nameOfCampaign, "1to1", "cost") + "</td></tr>");
}
if (cpaYesterday < (anticipatedDailyCpa * cpaUnderDelivery) && anticipatedDailyCpa != 0) {
campaignAlert = true;
Logger.log("		DAILY underdelivery CPA alert");
campaignErrors.push("<tr><td><strong>" + nameOfCampaign + "</strong></td><td>Daily underCPA</td><td>" + cpaYesterday + "</td><td>" + (cpaYesterday / anticipatedDailyCpa).toFixed(2) * 100 + "%</td><td> Spent: " + findAMetric(nameOfCampaign, "1to1", "cost") + "</td></tr>");
}
}
if (checkDailyCtr && adSpendYesterday > minimalSpend) {
var ctrYesterday = findAMetric(nameOfCampaign, "1to1", "cpa");
var ctrYesterday = ctrYesterday.replace(/,|%/g, "");
if (ctrYesterday > (anticipatedDailyCtr * ctrOverDelivery) && anticipatedDailyCtr != 0) {
campaignAlert = true;
Logger.log("		DAILY overdelivery CTR alert");
campaignErrors.push("<tr><td><strong>" + nameOfCampaign + "</strong></td><td>Daily overCTR</td><td>" + ctrYesterday + "</td><td>" + (ctrYesterday / anticipatedDailyCtr).toFixed(2) * 100 + "%</td><td> Spent: " + findAMetric(nameOfCampaign, "1to1", "cost") + "</td></tr>");
}
if (ctrYesterday < (anticipatedDailyCtr * ctrUnderDelivery) && anticipatedDailyCtr != 0) {
campaignAlert = true;
Logger.log("		DAILY underdelivery CTR alert");
campaignErrors.push("<tr><td><strong>" + nameOfCampaign + "</strong></td><td>Daily underCTR</td><td>" + ctrYesterday + "</td><td>" + (ctrYesterday / anticipatedDailyCtr).toFixed(2) * 100 + "%</td><td> Spent: " + findAMetric(nameOfCampaign, "1to1", "cost") + "</td></tr>");
}
}
}
// weekly check
if (checkWeekly) {
var adSpendWeekly = findAMetric(nameOfCampaign, "7to1", "cost");
var adSpendWeekly = adSpendWeekly.replace(/,|%/g, "");
if (checkWeeklyAdSpend) {
if (adSpendWeekly > (anticipatedWeeklyAdSpend * adSpendOverDelivery) && anticipatedWeeklyAdSpend != 0) {
campaignAlert = true;
Logger.log("		WEEKLY overdelivery spend alert");
campaignErrors.push("<tr><td><strong>" + nameOfCampaign + "</strong></td><td>Weekly overspend</td><td>" + adSpendWeekly + "</td><td>" + (adSpendWeekly / anticipatedWeeklyAdSpend).toFixed(2) * 100 + "%</td><td> Spent: " + findAMetric(nameOfCampaign, "7to1", "cost") + "</td></tr>");
}
if (adSpendWeekly < (anticipatedWeeklyAdSpend * adSpendUnderDelivery) && anticipatedWeeklyAdSpend != 0) {
campaignAlert = true;
Logger.log("		WEEKLY underdelivery spend alert");
campaignErrors.push("<tr><td><strong>" + nameOfCampaign + "</strong></td><td>Weekly underspend</td><td>" + adSpendWeekly + "</td><td>" + (adSpendWeekly / anticipatedWeeklyAdSpend).toFixed(2) * 100 + "%</td><td> Spent: " + findAMetric(nameOfCampaign, "7to1", "cost") + "</td></tr>");
}
}
if (checkWeeklyCpa && adSpendWeekly > minimalSpend) {
var cpaWeekly = findAMetric(nameOfCampaign, "7to1", "cpa");
var cpaWeekly = cpaWeekly.replace(/,|%/g, "");
if (cpaWeekly > (anticipatedWeeklyCpa * cpaOverDelivery) && anticipatedWeeklyCpa != 0) {
campaignAlert = true;
Logger.log("		WEEKLY overdelivery CPA alert");
campaignErrors.push("<tr><td><strong>" + nameOfCampaign + "</strong></td><td>Weekly overCPA</td><td>" + cpaWeekly + "</td><td>" + (cpaWeekly / anticipatedWeeklyCpa).toFixed(2) * 100 + "%</td><td> Spent: " + findAMetric(nameOfCampaign, "7to1", "cost") + "</td></tr>");
}
if (cpaWeekly < (anticipatedWeeklyCpa * cpaUnderDelivery) && anticipatedWeeklyCpa != 0) {
campaignAlert = true;
Logger.log("		WEEKLY underdelivery CPA alert");
campaignErrors.push("<tr><td><strong>" + nameOfCampaign + "</strong></td><td>Weekly underCPA</td><td>" + cpaWeekly + "</td><td>" + (cpaWeekly / anticipatedWeeklyCpa).toFixed(2) * 100 + "%</td><td> Spent: " + findAMetric(nameOfCampaign, "7to1", "cost") + "</td></tr>");
}
}
if (checkWeeklyCtr && adSpendWeekly > minimalSpend) {
var ctrWeekly = findAMetric(nameOfCampaign, "7to1", "ctr");
var ctrWeekly = ctrWeekly.replace(/,|%/g, "");
if (ctrWeekly > (anticipatedWeeklyCtr * ctrOverDelivery) && anticipatedWeeklyCtr != 0) {
campaignAlert = true;
Logger.log("		WEEKLY overdelivery CTR alert");
campaignErrors.push("<tr><td><strong>" + nameOfCampaign + "</strong></td><td>Weekly overCTR</td><td>" + ctrWeekly + "</td><td>" + (ctrWeekly / anticipatedWeeklyCtr).toFixed(2) * 100 + "%</td><td> Spent: " + findAMetric(nameOfCampaign, "7to1", "cost") + "</td></tr>");
}
if (ctrWeekly < (anticipatedWeeklyCtr * ctrUnderDelivery) && anticipatedWeeklyCtr != 0) {
campaignAlert = true;
Logger.log("		WEEKLY underdelivery CTR alert");
campaignErrors.push("<tr><td><strong>" + nameOfCampaign + "</strong></td><td>Weekly underCTR</td><td>" + ctrWeekly + "</td><td>" + (ctrWeekly / anticipatedWeeklyCtr).toFixed(2) * 100 + "%</td><td> Spent: " + findAMetric(nameOfCampaign, "7to1", "cost") + "</td></tr>");
}
}
}
// 4 weekly check
if (checkMonthly) {
var adSpendMonthly = findAMetric(nameOfCampaign, "28to1", "cost");
var adSpendMonthly = adSpendMonthly.replace(/,|%/g, "");
if (checkMonthlyAdSpend) {
if (adSpendMonthly > (anticipatedMonthlyAdSpend * adSpendOverDelivery) && anticipatedMonthlyAdSpend != 0) {
campaignAlert = true;
Logger.log("		MONTHLY overdelivery spend alert");
campaignErrors.push("<tr><td><strong>" + nameOfCampaign + "</strong></td><td>Monthly overspend</td><td>" + adSpendMonthly + "</td><td>" + (adSpendMonthly / anticipatedMonthlyAdSpend).toFixed(2) * 100 + "%</td><td> Spent: " + findAMetric(nameOfCampaign, "28to1", "cost") + "</td></tr>");
}
if (adSpendMonthly < (anticipatedMonthlyAdSpend * adSpendUnderDelivery) && anticipatedMonthlyAdSpend != 0) {
campaignAlert = true;
Logger.log("		MONTHLY underdelivery spend alert");
campaignErrors.push("<tr><td><strong>" + nameOfCampaign + "</strong></td><td>Monthly underspend</td><td>" + adSpendMonthly + "</td><td>" + (adSpendMonthly / anticipatedMonthlyAdSpend).toFixed(2) * 100 + "%</td><td> Spent: " + findAMetric(nameOfCampaign, "28to1", "cost") + "</td></tr>");
}
}
if (checkMonthlyCpa && adSpendMonthly > minimalSpend) {
var cpaMonthly = findAMetric(nameOfCampaign, "28to1", "cpa");
var cpaMonthly = cpaMonthly.replace(/,|%/g, "");
if (cpaMonthly > (anticipatedMonthlyCpa * cpaOverDelivery) && anticipatedMonthlyCpa != 0) {
campaignAlert = true;
Logger.log("		MONTHLY overdelivery CPA alert");
campaignErrors.push("<tr><td><strong>" + nameOfCampaign + "</strong></td><td>Monthly overCPA</td><td>" + cpaMonthly + "</td><td>" + (cpaMonthly / anticipatedMonthlyCpa).toFixed(2) * 100 + "%</td><td> Spent: " + findAMetric(nameOfCampaign, "28to1", "cost") + "</td></tr>");
}
if (cpaMonthly < (anticipatedMonthlyCpa * cpaUnderDelivery) && anticipatedMonthlyCpa != 0) {
campaignAlert = true;
Logger.log("		MONTHLY underdelivery CPA alert");
campaignErrors.push("<tr><td><strong>" + nameOfCampaign + "</strong></td><td>Monthly underCPA</td><td>" + cpaMonthly + "</td><td>" + (cpaMonthly / anticipatedMonthlyCpa).toFixed(2) * 100 + "%</td><td> Spent: " + findAMetric(nameOfCampaign, "28to1", "cost") + "</td></tr>");
}
}
if (checkMonthlyCtr && adSpendMonthly > minimalSpend) {
var ctrMonthly = findAMetric(nameOfCampaign, "28to1", "ctr");
var ctrMonthly = ctrMonthly.replace(/,|%/g, "");
if (ctrMonthly > (anticipatedMonthlyCtr * ctrOverDelivery) && anticipatedMonthlyCtr != 0) {
campaignAlert = true;
Logger.log("		MONTHLY overdelivery CTR alert");
campaignErrors.push("<tr><td><strong>" + nameOfCampaign + "</strong></td><td>Monthly overCTR</td><td>" + ctrMonthly + "</td><td>" + (ctrMonthly / anticipatedMonthlyCtr).toFixed(2) * 100 + "%</td><td> Spent: " + findAMetric(nameOfCampaign, "28to1", "cost") + "</td></tr>");
}
if (ctrMonthly < (anticipatedMonthlyCtr * ctrUnderDelivery) && anticipatedMonthlyCtr != 0) {
campaignAlert = true;
Logger.log("		MONTHLY underdelivery CTR alert");
campaignErrors.push("<tr><td><strong>" + nameOfCampaign + "</strong></td><td>Monthly underCTR</td><td>" + ctrMonthly + "</td><td>" + (ctrMonthly / anticipatedMonthlyCtr).toFixed(2) * 100 + "%</td><td> Spent: " + findAMetric(nameOfCampaign, "28to1", "cost") + "</td></tr>");
}
}
}
}
}
// prepare email content
var html = [];
html.push(
"<html>",
"<body>",
"<p>Alerts for: ", AdsApp.currentAccount().getName(), "</p>",
"<p>Treshold value: ", minimalSpend, "</p>",
"<p>----------------------------------------------------</p>",
"<p>Issues found: </p>",
"<table>",
"<tr><td><strong>Campaign name</strong></td><td><strong>Issue</strong></td><td><strong>Cost/CPA/CTR</strong></td><td><strong>Change to previous period</strong></td><td><strong>Spent: Amount spent in given time range</strong></td></tr>", campaignErrors.join("\n"), "</table>",
"</body>",
"</html>"
);
// if there is any alert for any of the campaigns , send email
if (campaignAlert) {
var odeslano = MailApp.sendEmail(EMAIL, "Google Ads Campaign Alerts", "", {
htmlBody: html.join("\n")
});
}
// Function that selects the right metric from the array of campaigns
function findAMetric(campaignName, timeFrame, metric) {
for (var i = 0; i < listOfCampaigns.length; i++) {
if (listOfCampaigns[i].name == campaignName && listOfCampaigns[i].timeframe == timeFrame) {
if (metric == 'cost') {
return listOfCampaigns[i].cost;
break;
}
if (metric == 'cpa') {
return listOfCampaigns[i].cpa;
break;
}
if (metric == 'ctr') {
return listOfCampaigns[i].ctr;
break;
}
}
}
}
}
function lynt_format_awql_date(datum) {
return datum.getUTCFullYear() + ("0" + (datum.getUTCMonth() + 1)).slice(-2) + ("0" + datum.getUTCDate()).slice(-2);
}
//Daylight saving time correction
function lynt_DST(datum, offset) {
var yr = datum.getFullYear();
var dst_start = new Date("March 14, " + yr + " 02:00:00");
var dst_end = new Date("November 07, " + yr + " 02:00:00");
var day = dst_start.getDay();
dst_start.setDate(14 - day);
day = dst_end.getDay();
dst_end.setDate(7 - day);
if (datum >= dst_start && datum < dst_end) {
return offset + 1;
} else {
return offset;
}
}
//Getting data for x days back in right format for AWQL query
function lynt_get_date(pocet_dni, zona) {
var minule = new Date();
//kdyz je letni cas, tak o hodinu dele (GMT+1)
var offset = lynt_DST(minule, zona);
minule.setTime(minule.getTime() - (1000 * 60 * 60 * (24) * pocet_dni) + offset);
return lynt_format_awql_date(minule);
}

Pojďme k nastavení a kalibraci celého skriptu. Na začátku najdete několik proměnných, které je potřeba nastavit, aby skript správně fungoval.

První z nich je EMAIL. Sem nastavte e-mailovou adresu, na kterou chcete dostávat upozornění. Nezapomeňte na uvozovky.

Druhou je CAMPAIGNLABEL. Zde můžete nechat nastavenou výchozí hodnotu. Jedná se o štítek, který můžete použít ve chvíli, kdy nechcete, aby skript kontroloval určitou kampaň. Když v účtu kampaň označíte daným štítkem, třeba „noCampaignAlerts” (v účtu bez uvozovek ve skriptu s nimi), skript bude danou kampaň ignorovat. Doporučuji nastavit pouze u bezvýznamných kampaních, které v některé dny utratí 0 korun a je to u nich naprosto běžné a v pořádku.

Pak přichází na řadu nastavení prahových hodnot pro jednotlivé metriky adSpendOverDelivery, ctrUnderDelivery atd. Zde nastavíte horní a dolní hranici pro cenu, CPA a CTR. OverDelivery je horní hranice a koeficient, který zadáte říká, o kolik se musí lišit oproti dřívějšímu období, aby vyvolala poplach. Hodnota 10 tedy znamená “pošli upozornění, pokud kampaň utratila 10× víc než v minulém období”. Případně CTR bylo 10× vyšší atd. U spodní hranice – UnderDelivery – pak budete pracovat spíše s desetinnými čísly menšími než 1. Tedy pokud chcete poslat upozornění ve chvíli, kdy je útrata 10× nižší, použijete hodnotu 0.1.

Další, možná nejdůležitější parametr je „minimalSpend“. Ten udává od jaké minimální hodnoty útraty za kontrolované období bude zasílat upozornění. Pokud kampaň utratila jen 10 korun, je pravděpodobné, že její CTR i CPA bude dramaticky jiné než v předchozím období. Tyto metriky tedy chceme srovnávat až ve chvíli, kdy kampaň utratí nějakou významnější částku. Doporučuji tuto hranici nastavit na 3–5 násobek vaší průměrné CPA. Čím nižší tato hodnota bude, tím bude skript citlivější.

Pozor, tato hranice platí pouze pro CTR a CPA. Kontrola rozpočtu se provede vždy, protože když vám například cena kampaně klesne na 0, může to značit zásadní problém, ale 0 je samozřejmě pod nastavenou hranicí minimální útraty. Kampaně, kde je denní nulová útrata běžná, doporučuji odfiltrování pomocí štítku.

Pak následuje 12 položek, kterými můžete některé kontroly úplně vypnout. To uděláte přepsáním hodnoty „true“ na „false“ (bez uvozovek), případně 0. Můžete tak říct skriptu, že vůbec nechcete porovnávat včerejšek a předvčerejšek (checkDaily) nebo naopak, že vás nezajímá meziměsíční srovnání (checkMonthly). Níže pak můžete nastavit chování i pro jednotlivé metriky jako „neporovnávej denní změnu CPA“.

Většinu anomálií způsobených denními výkyvy však odfiltruje „minimalSpend“, není proto nezbytné vypínat např. denní srovnání CTR. Celkové denní/týdenní/měsíční nastavení má prioritu oproti jednotlivým metrikám, když tedy nastavíte checkWeekly na „false“ a checkWeeklyCpa na „true“, skript týdenní srovnání pro CPA NEprovede.

Předvyplněné nastavení berte jako velmi orientační. Vyzkoušejte kolik upozornění vám denně přijde a nastavte si hranice tak, aby odpovídaly průměrným hodnotám ve vašem účtu a aby vám upozornění chodilo jen, když se něco opravdu pokazí. Jinak hrozí, že upozornění, která vám bude chodit každodenně, začnete ignorovat a opravdový problém přehlédnete.

Na stránce s přehledem skriptů pak nastavte skriptu frekvenci spouštění. Já skript spouštím ráno kolem páté nebo šesté hodiny, abych měl upozornění na mailu když ráno zasednu k práci a zároveň abych dal účtům dostatek času, aby se ustálila data za včerejšek.

K dispozici mám i MCC verzi skriptu. Pokud byste o ní měli zájem, napište mi na info@vitousladislav.cz

Update: V novém rozhraní skriptů už funguje i pro Performance Max kampaně! Za upozornění děkuji Jakubu Vaškovi.

Napadá vás jiné využití než upozornění na vzniklé problémy? Myslíte, že by skript šlo napsat úsporněji nebo elegantněji? Jaké nastavení parametrů byste doporučili vy? Napište mi do komentářů!

Ladislav Vitouš

2 Comments

  • Martin

    8. 3. 2022

    Ahoj Láďo, super práce! Bylo by možné vytvořit i řešení, aby se v případě anomálie neposílal email, ale upozornění se posílali někam do Google sheetu? Děkuji.

    Reply
    • Ladislav Vitouš

      9. 3. 2022

      Ahoj Martine. To by snad neměl být problém. Pošlu e-mail a dohodneme se.

      Reply

Napsat komentář