Návod · Google Ads Scripts

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

Chcete jen hotové řešení? Přeskočte rovnou na sekci Nastavení a kalibrace skriptu, kde najdete skript i 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é.

Hledání řešení metodou pokus–omyl

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áhl 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.

Výkon a robustnost: někdy je potřeba zpomalit a zjednodušit

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://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("" + nameOfCampaign + "Daily overspend" + adSpendYesterday + "" + (adSpendYesterday / anticipatedDailyAdSpend).toFixed(2) * 100 + "% Spent: " + findAMetric(nameOfCampaign, "1to1", "cost") + "");
          }

          if (adSpendYesterday < (anticipatedDailyAdSpend * adSpendUnderDelivery) && anticipatedDailyAdSpend != 0) {
            campaignAlert = true;
            Logger.log("    DAILY underdelivery spend alert");
            campaignErrors.push("" + nameOfCampaign + "Daily underspend" + adSpendYesterday + "" + (adSpendYesterday / anticipatedDailyAdSpend).toFixed(2) * 100 + "% Spent: " + findAMetric(nameOfCampaign, "1to1", "cost") + "");
          }
        }

        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("" + nameOfCampaign + "Daily overCPA" + cpaYesterday + "" + (cpaYesterday / anticipatedDailyCpa).toFixed(2) * 100 + "% Spent: " + findAMetric(nameOfCampaign, "1to1", "cost") + "");
          }

          if (cpaYesterday < (anticipatedDailyCpa * cpaUnderDelivery) && anticipatedDailyCpa != 0) {
            campaignAlert = true;
            Logger.log("    DAILY underdelivery CPA alert");
            campaignErrors.push("" + nameOfCampaign + "Daily underCPA" + cpaYesterday + "" + (cpaYesterday / anticipatedDailyCpa).toFixed(2) * 100 + "% Spent: " + findAMetric(nameOfCampaign, "1to1", "cost") + "");
          }
        }

        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("" + nameOfCampaign + "Daily overCTR" + ctrYesterday + "" + (ctrYesterday / anticipatedDailyCtr).toFixed(2) * 100 + "% Spent: " + findAMetric(nameOfCampaign, "1to1", "cost") + "");
          }

          if (ctrYesterday < (anticipatedDailyCtr * ctrUnderDelivery) && anticipatedDailyCtr != 0) {
            campaignAlert = true;
            Logger.log("    DAILY underdelivery CTR alert");
            campaignErrors.push("" + nameOfCampaign + "Daily underCTR" + ctrYesterday + "" + (ctrYesterday / anticipatedDailyCtr).toFixed(2) * 100 + "% Spent: " + findAMetric(nameOfCampaign, "1to1", "cost") + "");
          }
        }
      }

      // 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("" + nameOfCampaign + "Weekly overspend" + adSpendWeekly + "" + (adSpendWeekly / anticipatedWeeklyAdSpend).toFixed(2) * 100 + "% Spent: " + findAMetric(nameOfCampaign, "7to1", "cost") + "");
          }

          if (adSpendWeekly < (anticipatedWeeklyAdSpend * adSpendUnderDelivery) && anticipatedWeeklyAdSpend != 0) {
            campaignAlert = true;
            Logger.log("    WEEKLY underdelivery spend alert");
            campaignErrors.push("" + nameOfCampaign + "Weekly underspend" + adSpendWeekly + "" + (adSpendWeekly / anticipatedWeeklyAdSpend).toFixed(2) * 100 + "% Spent: " + findAMetric(nameOfCampaign, "7to1", "cost") + "");
          }
        }

        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("" + nameOfCampaign + "Weekly overCPA" + cpaWeekly + "" + (cpaWeekly / anticipatedWeeklyCpa).toFixed(2) * 100 + "% Spent: " + findAMetric(nameOfCampaign, "7to1", "cost") + "");
          }

          if (cpaWeekly < (anticipatedWeeklyCpa * cpaUnderDelivery) && anticipatedWeeklyCpa != 0) {
            campaignAlert = true;
            Logger.log("    WEEKLY underdelivery CPA alert");
            campaignErrors.push("" + nameOfCampaign + "Weekly underCPA" + cpaWeekly + "" + (cpaWeekly / anticipatedWeeklyCpa).toFixed(2) * 100 + "% Spent: " + findAMetric(nameOfCampaign, "7to1", "cost") + "");
          }
        }

        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("" + nameOfCampaign + "Weekly overCTR" + ctrWeekly + "" + (ctrWeekly / anticipatedWeeklyCtr).toFixed(2) * 100 + "% Spent: " + findAMetric(nameOfCampaign, "7to1", "cost") + "");
          }

          if (ctrWeekly < (anticipatedWeeklyCtr * ctrUnderDelivery) && anticipatedWeeklyCtr != 0) {
            campaignAlert = true;
            Logger.log("    WEEKLY underdelivery CTR alert");
            campaignErrors.push("" + nameOfCampaign + "Weekly underCTR" + ctrWeekly + "" + (ctrWeekly / anticipatedWeeklyCtr).toFixed(2) * 100 + "% Spent: " + findAMetric(nameOfCampaign, "7to1", "cost") + "");
          }
        }
      }

      // monthly 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("" + nameOfCampaign + "Monthly overspend" + adSpendMonthly + "" + (adSpendMonthly / anticipatedMonthlyAdSpend).toFixed(2) * 100 + "% Spent: " + findAMetric(nameOfCampaign, "28to1", "cost") + "");
          }

          if (adSpendMonthly < (anticipatedMonthlyAdSpend * adSpendUnderDelivery) && anticipatedMonthlyAdSpend != 0) {
            campaignAlert = true;
            Logger.log("    MONTHLY underdelivery spend alert");
            campaignErrors.push("" + nameOfCampaign + "Monthly underspend" + adSpendMonthly + "" + (adSpendMonthly / anticipatedMonthlyAdSpend).toFixed(2) * 100 + "% Spent: " + findAMetric(nameOfCampaign, "28to1", "cost") + "");
          }
        }

        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("" + nameOfCampaign + "Monthly overCPA" + cpaMonthly + "" + (cpaMonthly / anticipatedMonthlyCpa).toFixed(2) * 100 + "% Spent: " + findAMetric(nameOfCampaign, "28to1", "cost") + "");
          }

          if (cpaMonthly < (anticipatedMonthlyCpa * cpaUnderDelivery) && anticipatedMonthlyCpa != 0) {
            campaignAlert = true;
            Logger.log("    MONTHLY underdelivery CPA alert");
            campaignErrors.push("" + nameOfCampaign + "Monthly underCPA" + cpaMonthly + "" + (cpaMonthly / anticipatedMonthlyCpa).toFixed(2) * 100 + "% Spent: " + findAMetric(nameOfCampaign, "28to1", "cost") + "");
          }
        }

        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("" + nameOfCampaign + "Monthly overCTR" + ctrMonthly + "" + (ctrMonthly / anticipatedMonthlyCtr).toFixed(2) * 100 + "% Spent: " + findAMetric(nameOfCampaign, "28to1", "cost") + "");
          }

          if (ctrMonthly < (anticipatedMonthlyCtr * ctrUnderDelivery) && anticipatedMonthlyCtr != 0) {
            campaignAlert = true;
            Logger.log("    MONTHLY underdelivery CTR alert");
            campaignErrors.push("" + nameOfCampaign + "Monthly underCTR" + ctrMonthly + "" + (ctrMonthly / anticipatedMonthlyCtr).toFixed(2) * 100 + "% Spent: " + findAMetric(nameOfCampaign, "28to1", "cost") + "");
          }
        }
      }

    }
  }

  // prepare email content
  var html = [];
  html.push(
    "",
    "",
    "

Alerts for: ", AdsApp.currentAccount().getName(), "

", "

Treshold value: ", minimalSpend, "

", "

----------------------------------------------------

", "

Issues found:

", "", "", campaignErrors.join("\n"), "
Campaign nameIssueCost/CPA/CTRChange to previous periodSpent: Amount spent in given time range
", "", "" ); // 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í, 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í“. U spodní hranice (UnderDelivery) pak budete pracovat spíše s desetinnými čísly menšími než 1 — pokud chcete upozornění ve chvíli, kdy je útrata 10× nižší, použijete hodnotu 0.1.

Ukázka kódu a kalibrace prahových hodnot

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 odfiltrovat 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 (např. „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 budou 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ářů!

Chcete, aby PPC a data dávaly smysl i mimo Google Ads rozhraní?

Pokud řešíte výkyvy výkonu, chaos v měření nebo chcete kampaně řídit podle reality (zisk, marže, kontext), ozvěte se. Rychle zjistíme, jestli vám dokážu pomoct.

Poptat spolupráci

Nebo rovnou napište na info@vitousladislav.cz.