Free tools · Google Ads

13 Free Google Ads Scripts to Automate & Scale Your Account

Copy-and-use scripts that monitor your budget, catch wasted spend, flag broken pages and build your reporting — the automations we run for D2C brands, free for you to install in 2 minutes.

Free & MIT-licensed🔒 Safe — test in Preview2-min install🛒 Shopping & PMax ready
13 free Google Ads scripts by Digistex4u — monitor budgets, catch waste, flag broken pages and report

What are Google Ads scripts (and why they matter)

Google Ads scripts are small pieces of JavaScript that run inside your Google Ads account and automate the repetitive, easy-to-forget work — checking budgets, pausing money-losing keywords, catching disapproved ads, flagging broken landing pages and building reports. They run on Google's servers on a schedule you set, so the work happens whether you're at your desk or not.

For a growing D2C brand, that's the difference between finding a wasted-spend problem three weeks late and getting an email the morning it starts. Every script below is free, documented, and does one useful job well. You don't need to be a developer — paste, tweak a few settings, and schedule.

Scripts handle the monitoring; strategy is what compounds. If you'd like the automations plus the thinking behind them, see how we run performance marketing and CRM automation for D2C brands — or browse the questions founders ask us most before your next agency call.

How to install a script in 2 minutes

  1. In Google Ads, open Tools → Bulk actions → Scripts and click the blue +.
  2. Paste the script's code (use the Copy button on any script below).
  3. Edit the CONFIG block at the top — your email, thresholds, and a blank Google Sheet URL where needed.
  4. Click Authorise, then Preview to see exactly what it would do — safely, with no changes.
  5. Click Save and set a schedule (daily / weekly). Done.

🔔 Monitoring & alerts

Know the moment something breaks — spend, impression share, disapprovals, anomalies.

Account budget monitor

↻ Daily

Emails you the moment month-to-date spend crosses a hard cap, or when the account is pacing to overshoot your monthly budget.

BudgetAlerts
◂ Show the script▾ Hide the script
/**
 * ACCOUNT BUDGET MONITOR
 * Emails you when month-to-date (MTD) spend crosses a hard cap, or when the account is
 * pacing to overshoot a monthly budget. Run daily (morning).
 *
 * Digistex4u — free & MIT. Test in PREVIEW first.
 * Want us to run this for your brand? Free audit → https://www.digistex4u.com/free-audit
 */
var CONFIG = {
  EMAIL: 'you@yourbrand.com',        // where alerts go (comma-separate for multiple)
  MONTHLY_BUDGET: 300000,            // your monthly budget in account currency (e.g. ₹300000)
  HARD_CAP: 320000,                  // email immediately if MTD spend exceeds this
  PACING_ALERT_PCT: 105             // email if projected month-end spend > this % of MONTHLY_BUDGET
};

function main() {
  var rows = AdsApp.search(
    'SELECT metrics.cost_micros FROM campaign WHERE segments.date DURING THIS_MONTH');
  var mtd = 0;
  while (rows.hasNext()) { mtd += Number(rows.next().metrics.costMicros) / 1e6; }

  var now = new Date();
  var dayOfMonth = now.getDate();
  var daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
  var projected = mtd / dayOfMonth * daysInMonth;
  var pacePct = (projected / CONFIG.MONTHLY_BUDGET) * 100;

  var msgs = [];
  if (mtd >= CONFIG.HARD_CAP) {
    msgs.push('🚨 MTD spend ' + fmt(mtd) + ' has crossed the HARD CAP of ' + fmt(CONFIG.HARD_CAP) + '.');
  }
  if (pacePct >= CONFIG.PACING_ALERT_PCT) {
    msgs.push('⚠️ Pacing to ' + fmt(projected) + ' by month end (' + pacePct.toFixed(0) +
      '% of the ' + fmt(CONFIG.MONTHLY_BUDGET) + ' budget).');
  }

  Logger.log('MTD: ' + fmt(mtd) + ' | Projected: ' + fmt(projected) + ' | Pace: ' + pacePct.toFixed(0) + '%');
  if (!msgs.length) return;

  var body = '<h3>Google Ads budget alert — ' + AdsApp.currentAccount().getName() + '</h3><ul><li>' +
    msgs.join('</li><li>') + '</li></ul>' +
    '<p>Month-to-date: <b>' + fmt(mtd) + '</b><br>Projected month-end: <b>' + fmt(projected) +
    '</b><br>Budget: ' + fmt(CONFIG.MONTHLY_BUDGET) + '</p>';
  body += '<hr style="margin:16px 0;border:none;border-top:1px solid #e5e7eb"><p style="font-family:Arial,sans-serif;font-size:13px;color:#8a8f98;margin:0">Free Google Ads script by <b>Digistex4u</b>. Want us to run this for your brand? <a href="https://www.digistex4u.com/free-audit">Get a free audit →</a></p>';
  MailApp.sendEmail({ to: CONFIG.EMAIL, subject: '[Google Ads] Budget alert — ' + AdsApp.currentAccount().getName(), htmlBody: body });
}

function fmt(n) { return AdsApp.currentAccount().getCurrencyCode() + ' ' + Math.round(n).toLocaleString(); }

Impression share monitor

↻ Daily

Alerts you when campaigns lose impression share to budget (raise budget) or to rank (improve bids/Quality Score) beyond a threshold.

Impression shareAlerts
◂ Show the script▾ Hide the script
/**
 * IMPRESSION SHARE MONITOR
 * Alerts when active campaigns are losing impression share to BUDGET or RANK above a threshold
 * over the last 7 days — the two clearest signals of "raise budget" vs "improve quality/bids".
 * Run daily.
 *
 * Digistex4u — free & MIT. Test in PREVIEW first.
 * Want us to run this for your brand? Free audit → https://www.digistex4u.com/free-audit
 */
var CONFIG = {
  EMAIL: 'you@yourbrand.com',
  LOST_IS_BUDGET_MAX: 0.10,   // alert if >10% impressions lost to budget
  LOST_IS_RANK_MAX: 0.20,     // alert if >20% impressions lost to rank
  MIN_IMPRESSIONS: 500        // ignore tiny campaigns
};

function main() {
  var rows = AdsApp.search(
    'SELECT campaign.name, metrics.impressions, ' +
    'metrics.search_budget_lost_impression_share, ' +
    'metrics.search_rank_lost_impression_share, ' +
    'metrics.search_impression_share ' +
    'FROM campaign ' +
    'WHERE campaign.status = "ENABLED" AND campaign.advertising_channel_type = "SEARCH" ' +
    'AND segments.date DURING LAST_7_DAYS');

  var flags = [];
  while (rows.hasNext()) {
    var r = rows.next();
    if (Number(r.metrics.impressions) < CONFIG.MIN_IMPRESSIONS) continue;
    var lostBudget = Number(r.metrics.searchBudgetLostImpressionShare || 0);
    var lostRank = Number(r.metrics.searchRankLostImpressionShare || 0);
    var isShare = Number(r.metrics.searchImpressionShare || 0);
    var reasons = [];
    if (lostBudget > CONFIG.LOST_IS_BUDGET_MAX) reasons.push('budget ' + pct(lostBudget) + ' → raise budget');
    if (lostRank > CONFIG.LOST_IS_RANK_MAX) reasons.push('rank ' + pct(lostRank) + ' → improve QS/bids');
    if (reasons.length) {
      flags.push('<tr><td>' + r.campaign.name + '</td><td>' + pct(isShare) +
        '</td><td>' + reasons.join('<br>') + '</td></tr>');
    }
  }

  Logger.log(flags.length + ' campaign(s) flagged for lost impression share.');
  if (!flags.length) return;

  var body = '<h3>Impression share alert — ' + AdsApp.currentAccount().getName() + '</h3>' +
    '<table border="1" cellpadding="6" style="border-collapse:collapse">' +
    '<tr><th>Campaign</th><th>Impr. share</th><th>Lost to (7d)</th></tr>' + flags.join('') + '</table>';
  body += '<hr style="margin:16px 0;border:none;border-top:1px solid #e5e7eb"><p style="font-family:Arial,sans-serif;font-size:13px;color:#8a8f98;margin:0">Free Google Ads script by <b>Digistex4u</b>. Want us to run this for your brand? <a href="https://www.digistex4u.com/free-audit">Get a free audit →</a></p>';
  MailApp.sendEmail({ to: CONFIG.EMAIL, subject: '[Google Ads] Impression-share alert', htmlBody: body });
}

function pct(n) { return (n * 100).toFixed(0) + '%'; }

Disapproved ads alert

↻ Daily

Emails a list of any disapproved ads the same day, so a policy issue never quietly kills your delivery.

AdsAlerts
◂ Show the script▾ Hide the script
/**
 * DISAPPROVED ADS ALERT
 * Emails a list of any disapproved ads (and the reason) so you can fix them the same day —
 * disapproved ads stop serving and quietly kill delivery. Run daily.
 *
 * Digistex4u — free & MIT. Test in PREVIEW first.
 * Want us to run this for your brand? Free audit → https://www.digistex4u.com/free-audit
 */
var CONFIG = { EMAIL: 'you@yourbrand.com' };

function main() {
  var rows = AdsApp.search(
    'SELECT campaign.name, ad_group.name, ad_group_ad.ad.id, ' +
    'ad_group_ad.ad.type, ad_group_ad.policy_summary.approval_status, ' +
    'ad_group_ad.policy_summary.review_status ' +
    'FROM ad_group_ad ' +
    'WHERE ad_group_ad.policy_summary.approval_status = "DISAPPROVED" ' +
    'AND ad_group_ad.status = "ENABLED" AND campaign.status = "ENABLED"');

  var list = [];
  while (rows.hasNext()) {
    var r = rows.next();
    list.push('<tr><td>' + r.campaign.name + '</td><td>' + r.adGroup.name +
      '</td><td>' + r.adGroupAd.ad.type + '</td><td>' + r.adGroupAd.ad.id + '</td></tr>');
  }

  Logger.log(list.length + ' disapproved ad(s).');
  if (!list.length) return;

  var body = '<h3>' + list.length + ' disapproved ad(s) — ' + AdsApp.currentAccount().getName() + '</h3>' +
    '<table border="1" cellpadding="6" style="border-collapse:collapse">' +
    '<tr><th>Campaign</th><th>Ad group</th><th>Type</th><th>Ad ID</th></tr>' + list.join('') + '</table>' +
    '<p>Open Google Ads → Ads → filter by "Disapproved" to see the exact policy reason and fix.</p>';
  body += '<hr style="margin:16px 0;border:none;border-top:1px solid #e5e7eb"><p style="font-family:Arial,sans-serif;font-size:13px;color:#8a8f98;margin:0">Free Google Ads script by <b>Digistex4u</b>. Want us to run this for your brand? <a href="https://www.digistex4u.com/free-audit">Get a free audit →</a></p>';
  MailApp.sendEmail({ to: CONFIG.EMAIL, subject: '[Google Ads] ' + list.length + ' disapproved ads', htmlBody: body });
}

Account anomaly detector

↻ Hourly

Compares today's spend, clicks and conversions to the recent norm and flags sharp deviations — catches tracking breaks and runaway spend.

AnomaliesAlerts
◂ Show the script▾ Hide the script
/**
 * ACCOUNT ANOMALY DETECTOR
 * Compares TODAY's spend / clicks / conversions (so far) against the same point-in-day average
 * over the previous N weeks. Emails you if any metric deviates beyond a tolerance — catches
 * tracking breaks, runaway spend, or a sudden drop. Run hourly.
 *
 * Inspired by Google's open-source Account Anomaly Detector (Apache-2.0), rewritten & simplified.
 * Digistex4u — free & MIT. Test in PREVIEW first.
 * Want us to run this for your brand? Free audit → https://www.digistex4u.com/free-audit
 */
var CONFIG = {
  EMAIL: 'you@yourbrand.com',
  WEEKS_LOOKBACK: 4,
  TOLERANCE_LOW: 0.5,   // alert if today < 50% of expected
  TOLERANCE_HIGH: 1.8   // alert if today > 180% of expected
};

function main() {
  var hour = new Date().getHours();
  var today = stats('TODAY');
  // average of the same weekday, up to the current hour, over the last N weeks
  var hist = { cost: 0, clicks: 0, conv: 0, n: 0 };
  for (var w = 1; w <= CONFIG.WEEKS_LOOKBACK; w++) {
    var d = dateNDaysAgo(7 * w);
    var s = statsForDate(d, hour);
    hist.cost += s.cost; hist.clicks += s.clicks; hist.conv += s.conv; hist.n++;
  }
  if (hist.n === 0) return;
  var exp = { cost: hist.cost / hist.n, clicks: hist.clicks / hist.n, conv: hist.conv / hist.n };

  var alerts = [];
  check('Cost', today.cost, exp.cost, alerts);
  check('Clicks', today.clicks, exp.clicks, alerts);
  check('Conversions', today.conv, exp.conv, alerts);

  Logger.log('Today so far — cost:' + today.cost.toFixed(0) + ' clicks:' + today.clicks + ' conv:' + today.conv);
  if (!alerts.length) return;
  var body = '<h3>⚠️ Anomaly detected — ' + AdsApp.currentAccount().getName() + '</h3><ul><li>' +
    alerts.join('</li><li>') + '</li></ul>';
  body += '<hr style="margin:16px 0;border:none;border-top:1px solid #e5e7eb"><p style="font-family:Arial,sans-serif;font-size:13px;color:#8a8f98;margin:0">Free Google Ads script by <b>Digistex4u</b>. Want us to run this for your brand? <a href="https://www.digistex4u.com/free-audit">Get a free audit →</a></p>';
  MailApp.sendEmail({ to: CONFIG.EMAIL, subject: '[Google Ads] Account anomaly detected', htmlBody: body });
}

function check(label, actual, expected, out) {
  if (expected < 1) return; // skip near-zero baselines
  var ratio = actual / expected;
  if (ratio < CONFIG.TOLERANCE_LOW || ratio > CONFIG.TOLERANCE_HIGH) {
    out.push(label + ': today ' + actual.toFixed(0) + ' vs expected ~' + expected.toFixed(0) +
      ' (' + (ratio * 100).toFixed(0) + '%)');
  }
}
function stats(range) {
  var r = AdsApp.search('SELECT metrics.cost_micros, metrics.clicks, metrics.conversions ' +
    'FROM customer WHERE segments.date DURING ' + range);
  return sum(r);
}
function statsForDate(d, uptoHour) {
  var ds = Utilities.formatDate(d, AdsApp.currentAccount().getTimeZone(), 'yyyy-MM-dd');
  var r = AdsApp.search('SELECT metrics.cost_micros, metrics.clicks, metrics.conversions, segments.hour ' +
    'FROM customer WHERE segments.date = "' + ds + '" AND segments.hour <= ' + uptoHour);
  return sum(r);
}
function sum(iter) {
  var o = { cost: 0, clicks: 0, conv: 0 };
  while (iter.hasNext()) { var x = iter.next(); o.cost += Number(x.metrics.costMicros) / 1e6; o.clicks += Number(x.metrics.clicks); o.conv += Number(x.metrics.conversions); }
  return o;
}
function dateNDaysAgo(n) { var d = new Date(); d.setDate(d.getDate() - n); return d; }

Day-of-week anomaly detector

↻ Daily

Baselines each campaign against the SAME weekday over the last 26 weeks and emails you only the campaigns whose CPC, cost, conversions, CPA or impression share broke 2 standard deviations from normal yesterday. The fastest way to catch fallout from a bidding change without watching dashboards.

AnomaliesBiddingAlerts
◂ Show the script▾ Hide the script
/**
 * Day-of-Week Anomaly Detector  ·  Google Ads Script
 * ----------------------------------------------------------------------------
 * Flags campaigns whose performance YESTERDAY broke from their normal pattern
 * for that weekday. The baseline is the SAME weekday over the previous 26 weeks
 * (e.g. if yesterday was a Tuesday, it compares against the 26 prior Tuesdays).
 *
 * Why day-of-week? Weekday and weekend traffic behave nothing alike, so a plain
 * trailing-28-day average smears them together and hides real shifts. Baselining
 * per weekday is the whole point — and it's the fastest way to catch the fallout
 * from a bidding change (like Google's Aug 2026 target-based bidding update),
 * a tracking break, or runaway spend, without watching dashboards all day.
 *
 * Metrics checked : avg CPC, cost, conversions, conversion rate, cost/conv,
 *                   search impression share.
 * Flag rule       : yesterday's value is >= SD_THRESHOLD standard deviations
 *                   from its 26-week weekday mean, in EITHER direction.
 * Output          : ONE summary email listing only the flagged campaigns.
 *                   Sends nothing if nothing is flagged.
 *
 * Safe by design  : read-only. It never changes your account — it only reads and
 *                   emails. Click Preview the first time to see the log.
 *
 * Free to use & modify (MIT).  By Digistex4u — https://www.digistex4u.com
 * Want us to run this for your brand? Free audit → https://www.digistex4u.com/free-audit
 *
 * ============================== CONFIG ==================================== */
var CONFIG = {
  RECIPIENT_EMAIL : 'you@yourbrand.com',  // where the alert email is sent
  SD_THRESHOLD    : 2.0,   // flag when |deviation| >= this many standard deviations
  LOOKBACK_WEEKS  : 26,    // size of the weekday baseline window
  MIN_DATA_POINTS : 15,    // need at least this many past same-weekday points to score
  MIN_CLICKS_YDAY : 10,    // skip near-zero-volume campaigns (yesterday's clicks)
  MIN_AVG_COST    : 1.0,   // skip campaigns whose baseline avg daily cost is below this
  ONLY_ENABLED    : true   // consider enabled campaigns only
};
/* ========================================================================= */

function main() {
  var tz  = AdsApp.currentAccount().getTimeZone();
  var cur = AdsApp.currentAccount().getCurrencyCode();

  var YDAY     = fmt(daysAgo(1), tz);                                  // yesterday
  var START    = fmt(daysAgo(1 + CONFIG.LOOKBACK_WEEKS * 7), tz);      // 26 weeks before
  var targetWD = weekdayOf(YDAY);                                      // 0=Sun .. 6=Sat

  // Pull the whole 26-week window in ONE query, then bucket by weekday in code
  // (far cheaper than issuing 26 separate weekday queries).
  var query =
    'SELECT campaign.id, campaign.name, segments.date, ' +
    'metrics.clicks, metrics.impressions, metrics.cost_micros, ' +
    'metrics.conversions, metrics.search_impression_share ' +
    'FROM campaign ' +
    "WHERE segments.date BETWEEN '" + START + "' AND '" + YDAY + "' " +
    (CONFIG.ONLY_ENABLED ? "AND campaign.status = 'ENABLED' " : '') +
    'AND metrics.impressions > 0';

  var rows  = AdsApp.search(query);
  var camps = {};   // id -> { name, days: { 'yyyy-MM-dd': {metrics} } }

  while (rows.hasNext()) {
    var r      = rows.next();
    var id     = String(r.campaign.id);
    var date   = r.segments.date;
    var clicks = num(r.metrics.clicks);
    var cost   = num(r.metrics.costMicros) / 1e6;
    var conv   = num(r.metrics.conversions);
    var sisRaw = r.metrics.searchImpressionShare;
    var sis    = (sisRaw === null || sisRaw === undefined) ? null : num(sisRaw);

    if (!camps[id]) camps[id] = { name: r.campaign.name, days: {} };
    camps[id].days[date] = {
      clicks    : clicks,
      cost      : cost,
      conv      : conv,
      avg_cpc   : clicks > 0 ? cost / clicks : null,   // guard div-by-zero
      conv_rate : clicks > 0 ? conv / clicks : null,
      cpa       : conv   > 0 ? cost / conv   : null,
      sis       : sis
    };
  }

  var METRICS = [
    { key: 'avg_cpc',   label: 'Avg CPC',                money: true  },
    { key: 'cost',      label: 'Cost',                   money: true  },
    { key: 'conv',      label: 'Conversions',            money: false },
    { key: 'conv_rate', label: 'Conversion rate',        pct: true    },
    { key: 'cpa',       label: 'Cost / conversion',      money: true  },
    { key: 'sis',       label: 'Search impression share', pct: true   }
  ];

  var flags = [];

  for (var id in camps) {
    var c  = camps[id];
    var yd = c.days[YDAY];
    if (!yd) continue;                                  // no data for yesterday
    if (yd.clicks < CONFIG.MIN_CLICKS_YDAY) continue;   // volume floor

    // gather prior points on the SAME weekday (excluding yesterday itself)
    var hist = [];
    for (var d in c.days) {
      if (d !== YDAY && weekdayOf(d) === targetWD) hist.push(c.days[d]);
    }
    if (hist.length < CONFIG.MIN_DATA_POINTS) continue; // not enough history

    if (mean(pluck(hist, 'cost')) < CONFIG.MIN_AVG_COST) continue; // trivial spend

    for (var m = 0; m < METRICS.length; m++) {
      var mk   = METRICS[m].key;
      var yVal = yd[mk];
      if (yVal === null || yVal === undefined) continue;      // metric N/A yesterday
      var vals = pluck(hist, mk);
      if (vals.length < CONFIG.MIN_DATA_POINTS) continue;
      var mu = mean(vals);
      var sd = stdev(vals, mu);
      if (sd === 0) continue;                                 // zero variance — can't score
      var z = (yVal - mu) / sd;
      if (Math.abs(z) >= CONFIG.SD_THRESHOLD) {
        flags.push({ camp: c.name, metric: METRICS[m].label, yVal: yVal, mean: mu, z: z, fmt: METRICS[m] });
      }
    }
  }

  if (!flags.length) {
    Logger.log('No anomalies flagged for ' + YDAY + '. No email sent.');
    return;
  }

  flags.sort(function(a, b){ return Math.abs(b.z) - Math.abs(a.z); });   // biggest deviation first

  var acct    = AdsApp.currentAccount().getName() || AdsApp.currentAccount().getCustomerId();
  var word    = flags.length > 1 ? 'anomalies' : 'anomaly';
  var subject = '[Google Ads] ' + flags.length + ' ' + word + ' flagged — ' + acct + ' (' + YDAY + ')';

  var out = [];
  out.push('Day-of-week anomaly check — ' + acct);
  out.push('Yesterday: ' + YDAY + ' (' + weekdayName(targetWD) + ')   ·   baseline: last ' +
           CONFIG.LOOKBACK_WEEKS + ' ' + weekdayName(targetWD) + 's   ·   threshold: ' +
           CONFIG.SD_THRESHOLD + ' SD');
  out.push('');
  for (var i = 0; i < flags.length; i++) {
    var f = flags[i], arrow = f.z > 0 ? '▲ up' : '▼ down';
    out.push(arrow + '  ' + f.camp);
    out.push('      ' + f.metric + ': ' + showVal(f.yVal, f.fmt, cur) +
             '   (normal ' + showVal(f.mean, f.fmt, cur) + ')' +
             '   →  ' + (f.z > 0 ? '+' : '') + f.z.toFixed(1) + ' SD');
  }
  out.push('');
  out.push('An anomaly is a signal, not a verdict — investigate before acting.');
  out.push('— Free Google Ads script by Digistex4u · https://www.digistex4u.com/google-ads-scripts');
  out.push('Want us to run this for your brand? Get a free audit → https://www.digistex4u.com/free-audit');

  MailApp.sendEmail(CONFIG.RECIPIENT_EMAIL, subject, out.join('\n'));
  Logger.log('Sent ' + flags.length + ' anomaly flag(s) to ' + CONFIG.RECIPIENT_EMAIL);
}

/* ------------------------------- helpers --------------------------------- */
function daysAgo(n)        { var d = new Date(); d.setDate(d.getDate() - n); return d; }
function fmt(d, tz)        { return Utilities.formatDate(d, tz, 'yyyy-MM-dd'); }
function weekdayOf(s)      { var p = s.split('-'); return new Date(Date.UTC(+p[0], +p[1] - 1, +p[2])).getUTCDay(); }
function weekdayName(w)    { return ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'][w]; }
function num(v)           { var n = parseFloat(v); return isNaN(n) ? 0 : n; }
function pluck(arr, k)     { var o = []; for (var i = 0; i < arr.length; i++) { var v = arr[i][k]; if (v !== null && v !== undefined && !isNaN(v)) o.push(v); } return o; }
function mean(a)          { if (!a.length) return 0; var s = 0; for (var i = 0; i < a.length; i++) s += a[i]; return s / a.length; }
function stdev(a, mu)     { if (a.length < 2) return 0; var s = 0; for (var i = 0; i < a.length; i++) { var d = a[i] - mu; s += d * d; } return Math.sqrt(s / (a.length - 1)); }
function showVal(v, f, cur) {
  if (f.pct)   return (v * 100).toFixed(1) + '%';
  if (f.money) return cur + ' ' + v.toFixed(2);
  return v < 10 ? v.toFixed(2) : String(Math.round(v));
}

✂️ Optimisation & waste

Find and cut the money you're wasting on keywords and search terms.

Pause non-converting keywords

↻ Weekly

Finds keywords that spent over a limit in 30 days with zero conversions, and (optionally) pauses them. Dry-run by default.

KeywordsWaste
◂ Show the script▾ Hide the script
/**
 * PAUSE NON-CONVERTING KEYWORDS
 * Finds enabled keywords that spent over a limit in the last 30 days with ZERO conversions.
 * Reports them (and can auto-pause). Run weekly.
 *
 * SAFETY: DRY_RUN = true by default — it only reports. Set DRY_RUN = false to actually pause.
 * Digistex4u — free & MIT. Test in PREVIEW first.
 * Want us to run this for your brand? Free audit → https://www.digistex4u.com/free-audit
 */
var CONFIG = {
  EMAIL: 'you@yourbrand.com',
  MIN_COST: 20,        // flag keywords that spent more than this (account currency) with 0 conversions
  DATE_RANGE: 'LAST_30_DAYS',
  DRY_RUN: true        // <-- set to false to auto-pause the flagged keywords
};

function main() {
  var q = 'SELECT ad_group_criterion.resource_name, ad_group_criterion.keyword.text, ' +
    'campaign.name, ad_group.name, metrics.cost_micros, metrics.clicks ' +
    'FROM keyword_view ' +
    'WHERE segments.date DURING ' + CONFIG.DATE_RANGE + ' ' +
    'AND ad_group_criterion.status = "ENABLED" AND campaign.status = "ENABLED" ' +
    'AND metrics.conversions = 0 AND metrics.cost_micros > ' + Math.round(CONFIG.MIN_COST * 1e6);

  var rows = AdsApp.search(q);
  var resourceNames = [], report = [];
  while (rows.hasNext()) {
    var r = rows.next();
    resourceNames.push(r.adGroupCriterion.resourceName);
    report.push('<tr><td>' + r.campaign.name + '</td><td>' + r.adGroup.name + '</td><td>' +
      r.adGroupCriterion.keyword.text + '</td><td>' + fmt(Number(r.metrics.costMicros) / 1e6) +
      '</td><td>' + r.metrics.clicks + '</td></tr>');
  }

  Logger.log(resourceNames.length + ' non-converting keyword(s) found. DRY_RUN=' + CONFIG.DRY_RUN);
  if (!resourceNames.length) return;

  var paused = 0;
  if (!CONFIG.DRY_RUN) {
    var it = AdsApp.keywords().withResourceNames(resourceNames).get();
    while (it.hasNext()) { it.next().pause(); paused++; }
  }

  var body = '<h3>' + resourceNames.length + ' non-converting keyword(s) — ' +
    AdsApp.currentAccount().getName() + '</h3>' +
    '<p>' + (CONFIG.DRY_RUN ? 'DRY RUN — nothing paused. Review below, then set DRY_RUN=false.'
      : '✅ Auto-paused ' + paused + ' keyword(s).') + '</p>' +
    '<table border="1" cellpadding="6" style="border-collapse:collapse">' +
    '<tr><th>Campaign</th><th>Ad group</th><th>Keyword</th><th>Cost</th><th>Clicks</th></tr>' +
    report.join('') + '</table>';
  body += '<hr style="margin:16px 0;border:none;border-top:1px solid #e5e7eb"><p style="font-family:Arial,sans-serif;font-size:13px;color:#8a8f98;margin:0">Free Google Ads script by <b>Digistex4u</b>. Want us to run this for your brand? <a href="https://www.digistex4u.com/free-audit">Get a free audit →</a></p>';
  MailApp.sendEmail({ to: CONFIG.EMAIL, subject: '[Google Ads] Non-converting keywords', htmlBody: body });
}
function fmt(n) { return AdsApp.currentAccount().getCurrencyCode() + ' ' + Math.round(n).toLocaleString(); }

Search-term N-gram waste finder

↻ Weekly

Breaks every search term into 1- and 2-word n-grams and surfaces the words costing money with no return — your negative-keyword shortlist, in a Sheet.

Search termsNegatives
◂ Show the script▾ Hide the script
/**
 * SEARCH-TERM N-GRAM WASTE FINDER
 * Breaks every search term (last 30 days) into 1-word and 2-word "n-grams", sums cost & conversions
 * per n-gram, and surfaces the words costing you money with little/no return — your negative-keyword
 * shortlist. Writes a ranked table to a Google Sheet. Run weekly.
 *
 * Digistex4u — free & MIT. Test in PREVIEW first.
 * Want us to run this for your brand? Free audit → https://www.digistex4u.com/free-audit
 */
var CONFIG = {
  SPREADSHEET_URL: 'PASTE_A_BLANK_GOOGLE_SHEET_URL_HERE',
  DATE_RANGE: 'LAST_30_DAYS',
  MIN_COST: 15,            // only report n-grams that spent more than this
  MAX_CONVERSIONS: 0.5    // ...and converted at or below this (0.5 = basically none)
};

function main() {
  var rows = AdsApp.search(
    'SELECT search_term_view.search_term, metrics.cost_micros, metrics.clicks, metrics.conversions ' +
    'FROM search_term_view WHERE segments.date DURING ' + CONFIG.DATE_RANGE);

  var grams = {}; // gram -> {cost, clicks, conv, terms}
  while (rows.hasNext()) {
    var r = rows.next();
    var term = (r.searchTermView.searchTerm || '').toLowerCase();
    var cost = Number(r.metrics.costMicros) / 1e6;
    var clicks = Number(r.metrics.clicks);
    var conv = Number(r.metrics.conversions);
    ngrams(term).forEach(function (g) {
      if (!grams[g]) grams[g] = { cost: 0, clicks: 0, conv: 0 };
      grams[g].cost += cost; grams[g].clicks += clicks; grams[g].conv += conv;
    });
  }

  var out = [];
  for (var g in grams) {
    var d = grams[g];
    if (d.cost >= CONFIG.MIN_COST && d.conv <= CONFIG.MAX_CONVERSIONS) {
      out.push([g, round(d.cost), d.clicks, round(d.conv), d.clicks ? round(d.cost / d.clicks) : 0]);
    }
  }
  out.sort(function (a, b) { return b[1] - a[1]; });

  Logger.log(out.length + ' wasteful n-grams found.');
  var sheet = SpreadsheetApp.openByUrl(CONFIG.SPREADSHEET_URL).getActiveSheet();
  sheet.clear();
  sheet.appendRow(['N-gram (candidate negative)', 'Cost', 'Clicks', 'Conversions', 'CPC']);
  if (out.length) sheet.getRange(2, 1, out.length, 5).setValues(out);
  sheet.getRange('A1:E1').setFontWeight('bold');
}

function ngrams(term) {
  var words = term.split(/\s+/).filter(function (w) { return w.length > 1; });
  var set = {};
  for (var i = 0; i < words.length; i++) {
    set[words[i]] = 1;
    if (i < words.length - 1) set[words[i] + ' ' + words[i + 1]] = 1;
  }
  return Object.keys(set);
}
function round(n) { return Math.round(n * 100) / 100; }

Negative keyword conflict finder

↻ Weekly

Flags negative keywords that are silently blocking your own active keywords — a common cause of lost impressions.

KeywordsAudit
◂ Show the script▾ Hide the script
/**
 * NEGATIVE KEYWORD CONFLICT FINDER
 * Flags negative keywords that are blocking your own ENABLED keywords in the same ad group /
 * campaign — a common, silent cause of lost impressions. Heuristic (exact & phrase logic),
 * reports only. Run weekly.
 *
 * Digistex4u — free & MIT. Test in PREVIEW first.
 * Want us to run this for your brand? Free audit → https://www.digistex4u.com/free-audit
 */
var CONFIG = { EMAIL: 'you@yourbrand.com' };

function main() {
  // 1) collect enabled positive keywords by ad group
  var pos = {}; // adGroupId -> [ {text, match} ]
  var kq = AdsApp.search(
    'SELECT ad_group.id, ad_group_criterion.keyword.text, ad_group_criterion.keyword.match_type ' +
    'FROM keyword_view WHERE ad_group_criterion.status = "ENABLED" AND campaign.status = "ENABLED"');
  while (kq.hasNext()) {
    var k = kq.next();
    var id = k.adGroup.id;
    (pos[id] = pos[id] || []).push({ text: (k.adGroupCriterion.keyword.text || '').toLowerCase() });
  }

  // 2) walk ad-group negatives and test them against those positives
  var conflicts = [];
  var nq = AdsApp.search(
    'SELECT campaign.name, ad_group.name, ad_group.id, ' +
    'ad_group_criterion.keyword.text, ad_group_criterion.keyword.match_type ' +
    'FROM ad_group_criterion ' +
    'WHERE ad_group_criterion.negative = true AND ad_group_criterion.type = "KEYWORD"');
  while (nq.hasNext()) {
    var n = nq.next();
    var negText = (n.adGroupCriterion.keyword.text || '').toLowerCase();
    var negMatch = n.adGroupCriterion.keyword.matchType;
    var list = pos[n.adGroup.id] || [];
    list.forEach(function (p) {
      if (blocks(negText, negMatch, p.text)) {
        conflicts.push('<tr><td>' + n.campaign.name + '</td><td>' + n.adGroup.name +
          '</td><td>[-] ' + negText + ' (' + negMatch + ')</td><td>' + p.text + '</td></tr>');
      }
    });
  }

  Logger.log(conflicts.length + ' negative conflict(s) found.');
  if (!conflicts.length) return;
  var body = '<h3>' + conflicts.length + ' negative-keyword conflict(s) — ' +
    AdsApp.currentAccount().getName() + '</h3>' +
    '<p>These negatives are blocking your own active keywords. Remove or narrow them.</p>' +
    '<table border="1" cellpadding="6" style="border-collapse:collapse">' +
    '<tr><th>Campaign</th><th>Ad group</th><th>Negative</th><th>Blocks keyword</th></tr>' +
    conflicts.join('') + '</table>';
  body += '<hr style="margin:16px 0;border:none;border-top:1px solid #e5e7eb"><p style="font-family:Arial,sans-serif;font-size:13px;color:#8a8f98;margin:0">Free Google Ads script by <b>Digistex4u</b>. Want us to run this for your brand? <a href="https://www.digistex4u.com/free-audit">Get a free audit →</a></p>';
  MailApp.sendEmail({ to: CONFIG.EMAIL, subject: '[Google Ads] Negative keyword conflicts', htmlBody: body });
}

// does a negative (exact/phrase/broad) block a positive keyword's text?
function blocks(negText, negMatch, posText) {
  if (negMatch === 'EXACT') return negText === posText;
  // PHRASE and BROAD: every negative word must appear in the positive (phrase = consecutive)
  if (negMatch === 'PHRASE') return (' ' + posText + ' ').indexOf(' ' + negText + ' ') > -1;
  var negWords = negText.split(/\s+/), posWords = posText.split(/\s+/);
  return negWords.every(function (w) { return posWords.indexOf(w) > -1; });
}

🛒 Shopping & Performance Max

Surface the products quietly draining your Shopping and PMax budget.

Zero-conversion products

↻ Weekly

Lists Shopping / Performance Max products that spent over a limit with no conversions — the SKUs draining your budget. Reports to a Sheet + email.

ShoppingPMaxWaste
◂ Show the script▾ Hide the script
/**
 * ZERO-CONVERSION PRODUCTS (Shopping / Performance Max)
 * Lists products that spent over a limit in the last 30 days with NO conversions — the SKUs
 * quietly draining your Shopping/PMax budget. Exclude them or fix their feed/landing page.
 * Reports to a Sheet + email. Run weekly.
 *
 * Digistex4u — free & MIT. Test in PREVIEW first.
 * Want us to run this for your brand? Free audit → https://www.digistex4u.com/free-audit
 */
var CONFIG = {
  EMAIL: 'you@yourbrand.com',
  SPREADSHEET_URL: 'PASTE_A_BLANK_GOOGLE_SHEET_URL_HERE',
  DATE_RANGE: 'LAST_30_DAYS',
  MIN_COST: 25   // flag products that spent more than this with 0 conversions
};

function main() {
  var rows = AdsApp.search(
    'SELECT segments.product_item_id, segments.product_title, campaign.name, ' +
    'metrics.cost_micros, metrics.clicks, metrics.conversions ' +
    'FROM shopping_performance_view WHERE segments.date DURING ' + CONFIG.DATE_RANGE);

  var agg = {}; // itemId -> {title, cost, clicks, conv, campaigns}
  while (rows.hasNext()) {
    var r = rows.next();
    var id = r.segments.productItemId || '(unknown)';
    if (!agg[id]) agg[id] = { title: r.segments.productTitle || '', cost: 0, clicks: 0, conv: 0 };
    agg[id].cost += Number(r.metrics.costMicros) / 1e6;
    agg[id].clicks += Number(r.metrics.clicks);
    agg[id].conv += Number(r.metrics.conversions);
  }

  var out = [];
  for (var id in agg) {
    var d = agg[id];
    if (d.cost >= CONFIG.MIN_COST && d.conv === 0) {
      out.push([id, d.title, Math.round(d.cost * 100) / 100, d.clicks]);
    }
  }
  out.sort(function (a, b) { return b[2] - a[2]; });

  Logger.log(out.length + ' zero-conversion product(s) over ' + CONFIG.MIN_COST + '.');
  if (CONFIG.SPREADSHEET_URL.indexOf('http') === 0) {
    var sh = SpreadsheetApp.openByUrl(CONFIG.SPREADSHEET_URL).getActiveSheet();
    sh.clear();
    sh.appendRow(['Product ID', 'Title', 'Cost', 'Clicks']);
    if (out.length) sh.getRange(2, 1, out.length, 4).setValues(out);
    sh.getRange('A1:D1').setFontWeight('bold');
  }
  if (out.length && CONFIG.EMAIL.indexOf('@') > 0) {
    var top = out.slice(0, 25).map(function (r) {
      return '<tr><td>' + r[0] + '</td><td>' + r[1] + '</td><td>' + fmt(r[2]) + '</td><td>' + r[3] + '</td></tr>';
    }).join('');
    MailApp.sendEmail({
      to: CONFIG.EMAIL, subject: '[Google Ads] ' + out.length + ' products spending with 0 conversions',
      htmlBody: '<h3>Zero-conversion products — ' + AdsApp.currentAccount().getName() + '</h3>' +
        '<table border="1" cellpadding="6" style="border-collapse:collapse">' +
        '<tr><th>ID</th><th>Title</th><th>Cost</th><th>Clicks</th></tr>' + top + '</table>' + '<hr style="margin:16px 0;border:none;border-top:1px solid #e5e7eb"><p style="font-family:Arial,sans-serif;font-size:13px;color:#8a8f98;margin:0">Free Google Ads script by <b>Digistex4u</b>. Want us to run this for your brand? <a href="https://www.digistex4u.com/free-audit">Get a free audit →</a></p>'
    });
  }
}
function fmt(n) { return AdsApp.currentAccount().getCurrencyCode() + ' ' + Math.round(n).toLocaleString(); }

🩺 Quality & health

Never pay for a dead landing page; track Quality Score over time.

Broken landing page (404) checker

↻ Daily

Crawls the final URLs of your live ads and keywords and emails any that return a 404 or error — so you never pay for clicks to a dead page.

Landing pagesHealth
◂ Show the script▾ Hide the script
/**
 * BROKEN LANDING PAGE (URL) CHECKER
 * Crawls the final URLs of your enabled ads & keywords and emails any that return an error
 * (404, 5xx, timeouts) — so you never pay for clicks to a dead page. Run daily.
 *
 * Based on Google's open-source Link Checker (Apache-2.0), rewritten & simplified.
 * Digistex4u — free & MIT. Test in PREVIEW first.
 * Want us to run this for your brand? Free audit → https://www.digistex4u.com/free-audit
 */
var CONFIG = {
  EMAIL: 'you@yourbrand.com',
  MAX_URLS: 400,        // safety cap per run (scripts have a ~30-min limit)
  BAD_CODES: [0, 400, 403, 404, 410, 500, 502, 503, 504]
};

function main() {
  var urls = {};
  collect('SELECT ad_group_ad.ad.final_urls FROM ad_group_ad WHERE ad_group_ad.status = "ENABLED" AND campaign.status = "ENABLED"',
    function (r) { return r.adGroupAd && r.adGroupAd.ad ? r.adGroupAd.ad.finalUrls : null; }, urls);
  collect('SELECT ad_group_criterion.final_urls FROM keyword_view WHERE ad_group_criterion.status = "ENABLED" AND campaign.status = "ENABLED"',
    function (r) { return r.adGroupCriterion ? r.adGroupCriterion.finalUrls : null; }, urls);

  var list = Object.keys(urls).slice(0, CONFIG.MAX_URLS);
  var broken = [];
  list.forEach(function (u) {
    var code;
    try {
      code = UrlFetchApp.fetch(u, { muteHttpExceptions: true, followRedirects: true,
        validateHttpsCertificates: false }).getResponseCode();
    } catch (e) { code = 0; }
    if (CONFIG.BAD_CODES.indexOf(code) > -1) broken.push({ url: u, code: code });
  });

  Logger.log('Checked ' + list.length + ' URLs, ' + broken.length + ' broken.');
  if (!broken.length) return;
  var body = '<h3>' + broken.length + ' broken landing page(s) — ' + AdsApp.currentAccount().getName() + '</h3>' +
    '<table border="1" cellpadding="6" style="border-collapse:collapse"><tr><th>Status</th><th>URL</th></tr>' +
    broken.map(function (b) { return '<tr><td>' + (b.code || 'ERR') + '</td><td>' + b.url + '</td></tr>'; }).join('') +
    '</table><p>Pause the ads/keywords pointing here or fix the pages.</p>';
  body += '<hr style="margin:16px 0;border:none;border-top:1px solid #e5e7eb"><p style="font-family:Arial,sans-serif;font-size:13px;color:#8a8f98;margin:0">Free Google Ads script by <b>Digistex4u</b>. Want us to run this for your brand? <a href="https://www.digistex4u.com/free-audit">Get a free audit →</a></p>';
  MailApp.sendEmail({ to: CONFIG.EMAIL, subject: '[Google Ads] ' + broken.length + ' broken landing pages', htmlBody: body });
}

function collect(gaql, getUrls, into) {
  var rows = AdsApp.search(gaql);
  while (rows.hasNext()) {
    var arr = getUrls(rows.next());
    if (arr && arr.length) arr.forEach(function (u) { if (u) into[u] = 1; });
  }
}

Quality Score tracker

↻ Daily

Logs every keyword's Quality Score to a Google Sheet each day, so you can see the trend Google itself hides.

Quality ScoreTracking
◂ Show the script▾ Hide the script
/**
 * QUALITY SCORE TRACKER
 * Appends today's Quality Score for every enabled keyword to a Google Sheet, so you can watch
 * QS trend over time (Google only shows the current value). Run daily.
 *
 * Digistex4u — free & MIT. Test in PREVIEW first.
 * Want us to run this for your brand? Free audit → https://www.digistex4u.com/free-audit
 */
var CONFIG = {
  SPREADSHEET_URL: 'PASTE_A_BLANK_GOOGLE_SHEET_URL_HERE',
  MIN_IMPRESSIONS: 1   // keywords need impressions (last 30d) to have a QS
};

function main() {
  var ss = SpreadsheetApp.openByUrl(CONFIG.SPREADSHEET_URL);
  var sheet = ss.getSheetByName('QS Log') || ss.insertSheet('QS Log');
  if (sheet.getLastRow() === 0) sheet.appendRow(['Date', 'Campaign', 'Ad group', 'Keyword', 'Quality Score']);

  var today = Utilities.formatDate(new Date(), AdsApp.currentAccount().getTimeZone(), 'yyyy-MM-dd');
  var rows = AdsApp.search(
    'SELECT campaign.name, ad_group.name, ad_group_criterion.keyword.text, ' +
    'ad_group_criterion.quality_info.quality_score, metrics.impressions ' +
    'FROM keyword_view ' +
    'WHERE ad_group_criterion.status = "ENABLED" AND campaign.status = "ENABLED" ' +
    'AND segments.date DURING LAST_30_DAYS AND metrics.impressions >= ' + CONFIG.MIN_IMPRESSIONS);

  var out = [];
  while (rows.hasNext()) {
    var r = rows.next();
    var qs = r.adGroupCriterion.qualityInfo ? r.adGroupCriterion.qualityInfo.qualityScore : '';
    if (qs) out.push([today, r.campaign.name, r.adGroup.name, r.adGroupCriterion.keyword.text, qs]);
  }
  Logger.log('Logging QS for ' + out.length + ' keywords.');
  if (out.length) sheet.getRange(sheet.getLastRow() + 1, 1, out.length, 5).setValues(out);
}

📊 Reporting

Your own always-fresh Google Sheets — no manual exports.

Daily performance → Google Sheet

↻ Daily

Appends yesterday's per-campaign performance (cost, conversions, ROAS, CPA…) to a Sheet — your own live reporting tab, zero manual exports.

ReportingSheets
◂ Show the script▾ Hide the script
/**
 * DAILY PERFORMANCE → GOOGLE SHEET
 * Appends yesterday's per-campaign performance (cost, clicks, conv, conv value, ROAS, CPA…) to a
 * Google Sheet — your own always-fresh reporting tab, no manual exports. Run daily (morning).
 *
 * Digistex4u — free & MIT. Test in PREVIEW first.
 * Want us to run this for your brand? Free audit → https://www.digistex4u.com/free-audit
 */
var CONFIG = { SPREADSHEET_URL: 'PASTE_A_BLANK_GOOGLE_SHEET_URL_HERE' };

function main() {
  var ss = SpreadsheetApp.openByUrl(CONFIG.SPREADSHEET_URL);
  var sheet = ss.getSheetByName('Daily') || ss.insertSheet('Daily');
  if (sheet.getLastRow() === 0) {
    sheet.appendRow(['Date', 'Campaign', 'Channel', 'Cost', 'Clicks', 'Impr.', 'CTR%',
      'Conversions', 'Conv. value', 'ROAS', 'CPA', 'Avg CPC']);
    sheet.getRange('A1:L1').setFontWeight('bold');
  }

  var date = Utilities.formatDate(dateYesterday(), AdsApp.currentAccount().getTimeZone(), 'yyyy-MM-dd');
  var rows = AdsApp.search(
    'SELECT campaign.name, campaign.advertising_channel_type, ' +
    'metrics.cost_micros, metrics.clicks, metrics.impressions, ' +
    'metrics.conversions, metrics.conversions_value ' +
    'FROM campaign WHERE segments.date DURING YESTERDAY AND metrics.impressions > 0');

  var out = [];
  while (rows.hasNext()) {
    var r = rows.next();
    var cost = Number(r.metrics.costMicros) / 1e6;
    var clicks = Number(r.metrics.clicks);
    var impr = Number(r.metrics.impressions);
    var conv = Number(r.metrics.conversions);
    var val = Number(r.metrics.conversionsValue);
    out.push([date, r.campaign.name, r.campaign.advertisingChannelType,
      round(cost), clicks, impr, impr ? round(clicks / impr * 100) : 0,
      round(conv), round(val), cost ? round(val / cost) : 0,
      conv ? round(cost / conv) : 0, clicks ? round(cost / clicks) : 0]);
  }
  Logger.log('Appending ' + out.length + ' campaign rows for ' + date);
  if (out.length) sheet.getRange(sheet.getLastRow() + 1, 1, out.length, 12).setValues(out);
}
function dateYesterday() { var d = new Date(); d.setDate(d.getDate() - 1); return d; }
function round(n) { return Math.round(n * 100) / 100; }

Ad schedule (dayparting) heatmap

↻ Weekly

Builds a day-of-week × hour performance heatmap in a Sheet, so you can set ad-schedule bid adjustments with real data.

DaypartingBidding
◂ Show the script▾ Hide the script
/**
 * AD SCHEDULE (DAYPARTING) HEATMAP
 * Builds a day-of-week × hour grid of performance (cost, conversions, CPA) over the last 30 days
 * in a Google Sheet, so you can see exactly when your account converts — and set ad-schedule bid
 * adjustments with confidence. Run weekly.
 *
 * Digistex4u — free & MIT. Test in PREVIEW first.
 * Want us to run this for your brand? Free audit → https://www.digistex4u.com/free-audit
 */
var CONFIG = {
  SPREADSHEET_URL: 'PASTE_A_BLANK_GOOGLE_SHEET_URL_HERE',
  METRIC: 'CPA'   // what to show in the grid: 'CPA', 'COST', or 'CONV'
};
var DAYS = ['MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', 'SUNDAY'];

function main() {
  // grid[day][hour] = {cost, conv}
  var grid = {};
  DAYS.forEach(function (d) { grid[d] = {}; for (var h = 0; h < 24; h++) grid[d][h] = { cost: 0, conv: 0 }; });

  var rows = AdsApp.search(
    'SELECT segments.day_of_week, segments.hour, metrics.cost_micros, metrics.conversions ' +
    'FROM campaign WHERE segments.date DURING LAST_30_DAYS');
  while (rows.hasNext()) {
    var r = rows.next();
    var d = r.segments.dayOfWeek, h = Number(r.segments.hour);
    if (!grid[d]) continue;
    grid[d][h].cost += Number(r.metrics.costMicros) / 1e6;
    grid[d][h].conv += Number(r.metrics.conversions);
  }

  var header = ['Hour \\ Day'].concat(DAYS.map(function (d) { return d.substr(0, 3); }));
  var table = [header];
  for (var h = 0; h < 24; h++) {
    var row = [pad(h) + ':00'];
    DAYS.forEach(function (d) {
      var c = grid[d][h];
      var v = CONFIG.METRIC === 'COST' ? c.cost : CONFIG.METRIC === 'CONV' ? c.conv
        : (c.conv ? c.cost / c.conv : 0); // CPA
      row.push(Math.round(v * 100) / 100);
    });
    table.push(row);
  }

  var ss = SpreadsheetApp.openByUrl(CONFIG.SPREADSHEET_URL);
  var sheet = ss.getSheetByName('Heatmap') || ss.insertSheet('Heatmap');
  sheet.clear();
  sheet.getRange(1, 1, table.length, table[0].length).setValues(table);
  sheet.getRange(1, 1, 1, table[0].length).setFontWeight('bold');
  // conditional colour scale over the data cells for a real "heatmap"
  var dataRange = sheet.getRange(2, 2, 24, DAYS.length);
  var rule = SpreadsheetApp.newConditionalFormatRule()
    .setGradientMinpoint('#57bb8a').setGradientMidpointWithValue('#ffd666', SpreadsheetApp.InterpolationType.PERCENTILE, '50')
    .setGradientMaxpoint('#e67c73').setRanges([dataRange]).build();
  sheet.setConditionalFormatRules([rule]);
  Logger.log('Heatmap (' + CONFIG.METRIC + ') written.');
}
function pad(n) { return (n < 10 ? '0' : '') + n; }
📦 All 13 scripts · one download

Get the full Google Ads script pack

Every script on this page in one folder, with setup notes and a config cheat-sheet — free. We'll send you new scripts as we add them.

No spam. Unsubscribe anytime.

Want these run for your brand — with the strategy behind them?

Scripts are the easy part. Digistex4u runs Google Ads for D2C brands end to end — feed, structure, bidding, creative and reporting as one system. Book a free call and we'll wire up the right automations for your account.

FAQ

Google Ads scripts — questions

Are these Google Ads scripts really free?
Yes — every script on this page is free to copy, use and modify (MIT licence). They're original implementations plus adaptations of Google's open-source examples. No sign-up, no paywall.
How do I add a Google Ads script to my account?
In Google Ads go to Tools → Bulk actions → Scripts, click the + button, paste the code, edit the CONFIG block at the top (your email, thresholds, Sheet URL), click Authorise, then Preview to test. Save and set a schedule once you're happy.
Are Google Ads scripts safe to run?
Yes, if you test first. Always click Preview (not Run) the first time — it shows what the script would do without making changes. Reporting and alert scripts never change your account. The one script that can pause keywords is set to dry-run by default until you explicitly enable it.
Do these work for Shopping and Performance Max?
Several do — the budget monitor, anomaly detector, broken-URL checker and reporting scripts run account-wide, and there's a dedicated zero-conversion products script for Shopping and PMax.
Can you set these up and manage them for my brand?
Yes. Digistex4u runs Google Ads for D2C brands end to end — scripts, structure, feed, creative and reporting as one system. Book a free call and we'll wire up the right automations for your account.