Inspired Learning Group · Google & Meta · Companion to the forensic audit · September 2026

ILG Growth Playbook

What to change, in what order, which tools to use and which automations to run, so the same budget produces more Digistorm enquiries. Every move is tied to a number found in the audit.

Goal cost per Digistorm enquiry back to ≤£160 (Jul–Aug £214–£223) Horizon 90 days, peak autumn open-morning season Scripts 6 Google Ads MCC scripts, report-only by default

ILG does not need more budget or more campaigns. It needs to stop paying twice for the same families, count one thing everywhere, and stop losing parents after they click or call. The ads already bring in the demand; ILG is losing it after the click, in the tracking, and through sister schools competing.

The strategy in four moves: (1) one enquiry signal everywhere, fed back to the platforms; (2) budget by cluster, with each town owned by one school; (3) fix what happens after the click: pages, phones, speed of response; (4) autumn open-morning campaigns with fresh creative every month. Automation keeps it from drifting back.

The strategy

Four moves, in order. Each one depends on the one before it.

1 · One enquiry signal, everywhere, fed back to the platforms

Weeks 1–4

Why: Google’s counts run from 0.05× Digistorm (Orchard Cambridge) to 2.6× (St Francis College). Two Meta accounts optimise for an event that never fires. Smart bidding follows whatever each school happens to count, and budget was added to the schools that over-count.

  1. Google goals: in every account keep ONE primary action, the Digistorm enquiry. Set to Secondary: “Book Now” clicks, swim forms, “Calls from ads”, email clicks, prospectus, and every duplicate form/thank-you action. Camps and swim campaigns get campaign-level goals so they stop feeding admissions bidding. Script 6 lists every offender.
  2. Fire the lead from the parent page: ask Digistorm to set each form’s completion action to redirect the top window to a school thank-you page on the same domain as the landing page (e.g. nursery.inspiredlearninggroup.co.uk/ruskin-house-thank-you/). One GTM trigger on that URL fires Google generate_lead and Meta Lead. Same tag set-up in all 30 containers.
  3. Capture the click ID in the form: add hidden fields gclid, fbclid, utm_source, utm_campaign to every Digistorm form (confirm with Digistorm that the embed passes page URL parameters into hidden fields).
  4. Feed real enquiries back (offline conversions): Digistorm export → Google Sheet → Google Ads scheduled upload (Goals → Uploads → Schedules, from Sheets) daily; the same rows to Meta via the Conversions API (Zapier/Make “Facebook Conversions” action, or Events Manager offline upload). Later, upload the stage (“Visited”, “Enrolled”) as a second, higher-value action so bidding learns which families enrol.
  5. Meta: one Lead event per school, one optimisation goal per ad set. Pause Rookwood until its pixel fires Lead; move Shebbear Senior to CompleteRegistration; switch St Felix nursery from Value to Conversions. Add server-side events (CAPI) to the Yateley, Derby, Royal Hospital and Wembley pixels.

Payoff: bidding that optimises towards enquiries Digistorm actually records. This is the single biggest long-term lever; everything else is measured with it.

2 · Budget by cluster, with each town owned by one school

Weeks 1–6

Why: south-west London spend +47%, enquiries −2%. Four ILG schools bought the same New Malden searches (£3,675 in Jun–Aug). Essendene and Warlingham spend in each other’s towns.

  1. Three cluster budgets instead of 11 separate ones: SW London (Kingston, Westbury, The Study, Wimbledon, Hampton, Danesfield, Walton), Caterham (Essendene, Warlingham, Collingwood), Wembley (St Christopher’s, St Nicholas). One owner per cluster moves money between schools weekly on Digistorm cost per enquiry.
  2. Town ownership: run Script 4, give each shared town to the school with the lower cost per enquiry there, add it as a location exclusion in the other schools’ Google campaigns and remove the postcode from their Meta targeting.
  3. One ad per auction for generic searches: in SW London, test a single “ILG nurseries near you” Search campaign for generic terms (“nursery near me”, “day nursery”) that lands on a chooser page listing the 4–5 nearest ILG nurseries with distance, age range and a Book-a-tour button each. Keep each school’s own name and hyper-local terms in its own account. This stops ILG bidding against itself.
  4. Return the 11 cut-list schools to May–June budgets (−£16,436/month) and grow the 8 efficient schools in 20% steps. No more than one budget change per campaign or ad set a week (St Felix’s nursery ad set had seven).

Payoff: ≈£12k–16k/month released (audit estimate), with enquiries expected to stay flat because the extra July–August money bought 49 extra leads at £1,328 each.

3 · Fix what happens after the click

Weeks 1–8

Why: 88% of scored keyword spend is rated “below average” for landing-page experience; 283 ad calls were missed; nine pages have no tap-to-call; Prep pages send parents to a second site; Kingston’s tour button opens Hampton’s form.

  1. This week: fix the Kingston button, the placeholder images (Kingston, Wimbledon), St Nicholas’s mismatched numbers, and make every phone number a tel: link. Put the Digistorm form on the Eton End, Essendene and Westbury Prep pages.
  2. Rebuild the top 10 pages by spend (St Nicholas, Ruskin, Essendene, Orchard Wimbledon, Orchard Kew, St Christopher’s, Derby, Danesfield, GPPS, Orchard Kingston) to one template: school name + area in the H1, the next open-morning date, ages and hours, funded-hours eligibility, Ofsted grade, 6 real photos, a 30-second tour video, map/distance, the form visible without scrolling, and a sticky call button on mobile. The pages are Unbounce: A/B test the new template against the old with Unbounce’s split test, 50/50, for 3 weeks.
  3. Speed to lead: set a Digistorm automation that emails/texts the family within 5 minutes with a tour-booking link, and an admissions call-back target of the same working day. Parents enquire with 3–4 nurseries at once; the first to call back usually gets the visit.
  4. Phones: Script 2 sends each school its missed ad calls every Monday. Add call schedules to call assets so they only show when someone can answer; route unanswered calls to a central admissions line or voicemail-to-email. Log calls over 60 seconds as Digistorm enquiries.

Payoff: higher Quality Score lowers cost per click on the same keywords. On calls, 283 missed in 4½ months is ≈63 a month; winning back even a third is ≈20 conversations a month at no media cost.

4 · Autumn open-morning season and a creative system

From now to November

Why: Meta frequency 6–17 on the school accounts, Derby’s click rate 2.56% → 0.77% on one ad running since April, and Bright Horizons launched “Autumn Open Day” ads on 15–16 September. ILG ads still say “Book a tour”.

  1. Event campaigns per school: put the real open-morning date in the ads. Google: responsive search ads with a {COUNTDOWN(...)} customiser (“Open morning in 5 days”) and a Sitelink per date; Meta: event-date creative to the catchment plus retargeting of page visitors who didn’t enquire (last 30 days) and video viewers (50%+).
  2. Three new creatives per school per month, one each: parent testimonial video (15–20s, filmed on a phone), “day in the life” reel, and an offer or fact card (funded 15/30 hours, opening hours, open-morning date). Retire any ad above frequency 6 or with CTR below 0.8% for 7 days (Meta rule R3).
  3. Search copy by entry point: Prep and Senior ads written around the year-group entry the parent is choosing (Reception 2027, Year 3, Year 7, Sixth Form) rather than generic “independent school”, and point them at an enquiry or open-morning booking page.
  4. Brand and “near me”: add each school’s name as a negative in its generic campaigns and run it in a cheap brand campaign (£8,308 of brand searches sat in generic campaigns); move “near me” to exact match on its own budget for a 4-week test (currently £365 per conversion).

Payoff: open-morning bookings are ILG’s highest-intent enquiries and the season is now. Fresh creative is the only lever that brings Meta frequency and cost down without cutting reach.

90 days, week by week

WhenDoOwnerDone when
Week 1Install Scripts 1, 2, 5, 6. Fix Kingston button, placeholders, tel links, St Nicholas numbers. Pause Rookwood Meta, Shebbear Senior ad set, Yateley Audience Network. Search Partners/Display off. Find out why Wimbledon, Cambridge, St Christopher’s stopped spending. Appeal Eton End Meta account; settle Pewsey billing.PPC lead, webScript 1 email arrives with no “went dark” alerts
Week 2One primary Google conversion per account (Script 6 sheet goes to zero). Cut-list schools back to May–June budgets. School-name negatives + brand campaigns. Location exclusions from the audit.PPC leadScript 6 sheet empty; brand spend visible separately
Weeks 2–3Digistorm: top-window redirect to thank-you pages + hidden gclid/fbclid fields; one GTM lead tag in all containers. Meta: one Lead event per school, CAPI on 4 pixels. Open-morning campaigns live with dates.Tracking, Digistorm adminTest enquiry at 5 schools shows in Digistorm, Google and Meta within 24h
Weeks 3–4Cluster budgets and town ownership (Script 4). Launch SW London chooser-page test. First monthly creative drop (3 per school).Cluster owners, creativeNo town with 2+ ILG schools above £25/month
Weeks 4–8Offline conversion upload daily (Sheet → Google schedule, CAPI). Rebuilt page template A/B test on top 5 pages, then the next 5. Speed-to-lead automation in Digistorm.Tracking, web, admissions≥80% of Digistorm Google enquiries matched to a gclid
Weeks 6–10Once uploads are stable (≥30 per account per month or pooled), move Search to Maximise conversions on the imported action; Meta optimises on the CAPI Lead. Grow efficient schools 20% at a time.PPC leadCost per Digistorm enquiry falling 3 weeks running
Weeks 10–13Upload “Visited” and “Enrolled” stages; re-audit Sep–Nov against this report; set the January budget by cost per visit, not per enquiry.Marketing leadBudget decisions use cost per visit

Tools worth using

Chosen for ILG’s set-up: Digistorm forms, Unbounce pages, Supermetrics already licensed, 30 Google accounts under one MCC. Free or already paid for, unless marked.

ToolWhat it fixes at ILGCostEffort
Google Ads scripts (MCC)Daily guard, missed calls, search terms, overlap, page health, settings audit — the six below. Replaces most of what paid rule tools do.Free1 day
Google Ads scheduled uploads (Sheets)Imports Digistorm enquiries matched on gclid as the conversion bidding learns from.Free2–3 days
Zapier or MakeDigistorm export/webhook → Google Sheet → Meta Conversions API. Also the Slack/Teams alert when a school gets an enquiry.Paid, low1–2 days
Stape (server-side GTM) or Meta CAPI GatewayServer-side events for the Yateley, Derby, Royal Hospital and Wembley pixels; more complete Meta signal on iPhones.Paid, low1 day per pixel
UK call tracking: Mediahawk, ResponseTap or CallRailDynamic numbers on the landing pages so calls from the page (not just from call assets) are tracked; missed-call alerts, call recordings for admissions coaching, calls fed into Google Ads and GA4.Paid1 week
Microsoft ClarityHeatmaps and session recordings on the Unbounce pages: see whether parents scroll to the form, rage-click, or leave at the iframe.Free1 hour via GTM
Unbounce A/B testsTest the rebuilt page template against the current pages without developer time.Already paidPer page
Looker Studio + SupermetricsWeekly scorecard: spend from Google/Meta, enquiries from the Digistorm sheet, by school and cluster. Replaces the monthly spreadsheet with its pasted totals and duplicate rows.Already paid2 days
Google Ads EditorBulk location exclusions, negatives and network settings across 30 accounts in one pass.FreeHours
Meta Ad LibraryMonthly check of Bright Horizons, Busy Bees and local nurseries’ messages and open-day timing.Free30 min/month
Optmyzr (optional)Only if nobody can maintain scripts: rule engine and alerts across the MCC.Paid

Before uploading enquiry data to Google or Meta, or building customer lists from it, confirm the privacy notice on the Digistorm forms covers advertising measurement, and hash emails and phone numbers (Google and Meta upload tools do this).

Google Ads scripts

Six MCC scripts written for ILG’s accounts. All are report-only by default; the two that can change anything have a switch at the top set to false. The same files are in Downloads\ILG-Google-Ads-Scripts.

Install once

  1. Google Ads, signed in to the ILG MCC → Tools → Bulk actions → Scripts → +
  2. Paste a script, set the email and Sheet URL at the top
  3. Authorise, then Preview and read the log
  4. Set the schedule shown in the script header

Before switching anything on

Run report-only for a week. Check the sheets, agree the changes with each school, then set APPLY or APPLY_TABLET to true. Scripts cannot change network settings or conversion goals; those stay manual.

Script 1 · Daily spend & tracking guard

DailyEmails one summary each morning: accounts that went dark, clicks with no conversions for 3 days, spend spikes. Would have caught Wimbledon, Cambridge and St Christopher’s stopping on 1 September.1-daily-spend-and-tracking-guard.js
1-daily-spend-and-tracking-guard.js
/**
 * ILG – Daily spend & tracking guard (MCC script)
 * Runs across every account under the MCC once a day and emails ONE summary.
 * Report-only: changes nothing.
 *
 * Catches the problems found in the audit:
 *  - an account that suddenly stops spending (Orchard Wimbledon, Orchard Cambridge,
 *    St Christopher's went dark on 1 Sept and nobody noticed)
 *  - clicks with no conversions for several days (a broken tag or form)
 *  - a sudden spend spike (budget edits like the July wave)
 *
 * Install: Google Ads (MCC) > Tools > Bulk actions > Scripts > + > paste > Authorise >
 *          Preview > Schedule "Daily, 07:00".
 */
var EMAILS = 'admissions-marketing@example.com';   // comma-separated
var DARK_MIN_AVG_SPEND = 20;     // £/day average (prior 7 days) before a £0 day counts as "went dark"
var BLIND_MIN_CLICKS = 60;       // clicks in the last 3 days with 0 conversions = tracking alert
var SPIKE_RATIO = 1.6;           // yesterday vs prior-7-day average
var EXCLUDE_NAME = /camp|swim/i; // campaigns ignored when counting admissions conversions

function main() {
  var alerts = [];
  var accounts = AdsManagerApp.accounts().get();
  while (accounts.hasNext()) {
    var account = accounts.next();
    AdsManagerApp.select(account);
    var tz = AdsApp.currentAccount().getTimeZone();
    var days = lastNDates(8, tz);           // [d-8 ... d-1]
    var byDate = {};
    days.forEach(function (d) { byDate[d] = { cost: 0, clicks: 0, conv: 0 }; });

    var q = 'SELECT segments.date, campaign.name, metrics.cost_micros, metrics.clicks, metrics.conversions ' +
            'FROM campaign WHERE segments.date DURING LAST_14_DAYS AND metrics.impressions > 0';
    var rows = AdsApp.search(q);
    while (rows.hasNext()) {
      var r = rows.next();
      var d = r.segments.date;
      if (!byDate[d]) continue;
      byDate[d].cost += Number(r.metrics.costMicros) / 1e6;
      byDate[d].clicks += Number(r.metrics.clicks);
      if (!EXCLUDE_NAME.test(r.campaign.name)) byDate[d].conv += Number(r.metrics.conversions);
    }
    var y = byDate[days[7]];
    var prior = days.slice(0, 7).map(function (d) { return byDate[d]; });
    var avg = prior.reduce(function (s, x) { return s + x.cost; }, 0) / 7;
    var last3 = days.slice(5).map(function (d) { return byDate[d]; });
    var clicks3 = last3.reduce(function (s, x) { return s + x.clicks; }, 0);
    var conv3 = last3.reduce(function (s, x) { return s + x.conv; }, 0);
    var name = account.getName();

    if (avg >= DARK_MIN_AVG_SPEND && y.cost < 1) {
      alerts.push(['WENT DARK', name, 'Spent £0 yesterday vs £' + avg.toFixed(0) + '/day average. Check budgets, billing, paused campaigns.']);
    }
    if (clicks3 >= BLIND_MIN_CLICKS && conv3 === 0) {
      alerts.push(['NO CONVERSIONS', name, clicks3 + ' clicks in 3 days, 0 admissions conversions. Test-submit the form and check the tag.']);
    }
    if (avg > 0 && y.cost > avg * SPIKE_RATIO) {
      alerts.push(['SPEND SPIKE', name, '£' + y.cost.toFixed(0) + ' yesterday vs £' + avg.toFixed(0) + '/day average.']);
    }
  }
  if (alerts.length) {
    var html = '<p>' + alerts.length + ' issue(s) across ILG accounts:</p><table border="1" cellpadding="6" style="border-collapse:collapse">' +
      '<tr><th>Type</th><th>Account</th><th>Detail</th></tr>' +
      alerts.map(function (a) { return '<tr><td><b>' + a[0] + '</b></td><td>' + a[1] + '</td><td>' + a[2] + '</td></tr>'; }).join('') + '</table>';
    MailApp.sendEmail({ to: EMAILS, subject: 'ILG Google Ads – ' + alerts.length + ' alert(s)', htmlBody: html });
  }
  Logger.log(JSON.stringify(alerts));
}

function lastNDates(n, tz) {
  var out = [];
  for (var i = n; i >= 1; i--) {
    out.push(Utilities.formatDate(new Date(Date.now() - i * 86400000), tz, 'yyyy-MM-dd'));
  }
  return out;
}

Script 2 · Weekly missed-call list per school

WeeklySends each school the ad calls it missed last week (time, area code, campaign) and marketing a league table. Audit: 283 missed, 207 in office hours.2-weekly-missed-calls.js
2-weekly-missed-calls.js
/**
 * ILG – Weekly missed-call list per school (MCC script)
 * Sends each school's admissions contact the calls from its Google ads that were
 * MISSED in the last 7 days, plus a group summary to marketing.
 * Audit: 283 of 2,348 ad calls were missed (St Felix 55%, Wood Wharf 39%, Westbury 26%),
 * 207 of them during office hours.
 * Google only provides the caller's area code, not the full number, so the list
 * tells the school WHEN it lost calls and from WHERE - use it to fix phone cover.
 * Report-only. Schedule: Weekly, Monday 08:00.
 */
var MARKETING_EMAIL = 'admissions-marketing@example.com';
// Account name exactly as in Google Ads  ->  school admissions email
var SCHOOL_EMAILS = {
  'St. Felix School': 'admissions@example.com',
  'Wood Wharf Kindergarten': 'admissions@example.com',
  'Westbury House': 'admissions@example.com'
  // add the rest...
};

function main() {
  var summary = [];
  var accounts = AdsManagerApp.accounts().get();
  while (accounts.hasNext()) {
    var account = accounts.next();
    AdsManagerApp.select(account);
    var tz = AdsApp.currentAccount().getTimeZone();
    var since = Utilities.formatDate(new Date(Date.now() - 7 * 86400000), tz, 'yyyy-MM-dd HH:mm:ss');
    var q = 'SELECT call_view.start_call_date_time, call_view.call_status, call_view.call_duration_seconds, ' +
            'call_view.caller_area_code, campaign.name FROM call_view ' +
            "WHERE call_view.start_call_date_time >= '" + since + "'";
    var total = 0, missed = [];
    var rows = AdsApp.search(q);
    while (rows.hasNext()) {
      var row = rows.next();
      total++;
      if (row.callView.callStatus === 'MISSED') {
        missed.push([row.callView.startCallDateTime, row.callView.callerAreaCode || '', row.campaign.name]);
      }
    }
    if (!total) continue;
    var name = account.getName();
    summary.push([name, total, missed.length]);
    if (missed.length && SCHOOL_EMAILS[name]) {
      var html = '<p>' + missed.length + ' of ' + total + ' calls from your Google ads in the last 7 days were not answered.</p>' +
        '<table border="1" cellpadding="5" style="border-collapse:collapse"><tr><th>Time</th><th>Caller area code</th><th>Ad campaign</th></tr>' +
        missed.map(function (m) { return '<tr><td>' + m[0] + '</td><td>' + m[1] + '</td><td>' + m[2] + '</td></tr>'; }).join('') +
        '</table><p>Each missed call is a family who tried to reach you. Please check phone cover at these times.</p>';
      MailApp.sendEmail({ to: SCHOOL_EMAILS[name], cc: MARKETING_EMAIL, subject: name + ' – missed ad calls this week', htmlBody: html });
    }
  }
  summary.sort(function (a, b) { return b[2] / b[1] - a[2] / a[1]; });
  var body = '<table border="1" cellpadding="5" style="border-collapse:collapse"><tr><th>School</th><th>Calls</th><th>Missed</th><th>Missed %</th></tr>' +
    summary.map(function (s) { return '<tr><td>' + s[0] + '</td><td>' + s[1] + '</td><td>' + s[2] + '</td><td>' + Math.round(100 * s[2] / s[1]) + '%</td></tr>'; }).join('') + '</table>';
  MailApp.sendEmail({ to: MARKETING_EMAIL, subject: 'ILG – missed ad calls, last 7 days', htmlBody: body });
}

Script 3 · Search-term guard

WeeklyOne sheet of blocklist terms (free hours, term dates, jobs), own-brand terms in generic campaigns and zero-conversion terms. Optional: auto-adds blocklist terms as exact negatives.3-search-term-guard.js
3-search-term-guard.js
/**
 * ILG – Search-term guard (MCC script)
 * Last 30 days of search terms in every account, written to one Google Sheet with
 * three tabs:
 *   Blocklist   – free/funded-hours, term dates, jobs, holiday club, council, etc.
 *   Own brand   – the school's own name bought inside a NON-brand campaign
 *                 (audit: £8,308 in Jun–Aug vs £580 in the brand campaigns)
 *   Zero conv   – terms that cost more than MIN_COST with no conversions
 * APPLY = false  -> report only.
 * APPLY = true   -> adds each Blocklist term as an exact-match negative on the
 *                   campaign it came from. Review the sheet for a week before
 *                   switching this on.
 * Schedule: Weekly, Monday 06:00.
 */
var SHEET_URL = 'https://docs.google.com/spreadsheets/d/XXXX/edit'; // an empty sheet you own
var APPLY = false;
var MIN_COST = 40;
var BLOCK = /\b(free|funded|funding|15 hours|30 hours|tax[- ]free|term dates?|holiday club|jobs?|vacanc|careers?|salary|council|ofsted report|uniform|login|childminders?|apprentice)/i;
// Words that identify each school's own brand, by Google Ads account name
var BRAND = {
  'Westbury House': /westbury/i, 'Orchard Day Kingston': /orchard/i, 'Warlingham Park Prep': /warlingham/i,
  'Eton End': /eton end/i, 'Ruskin House Nursery': /ruskin/i, 'St. Felix School': /felix/i,
  'St Nicholas School': /nicholas/i, 'Sunninghill Prep School': /sunninghill/i, 'Orchard Day Wimbledon': /orchard/i,
  'Orchard Hampton Nursery': /orchard/i, 'Gidea Park': /gidea/i, 'GPPS': /grange park/i, 'The Study School': /\bstudy\b/i,
  'Yateley Manor School': /yateley manor|little lodge/i, 'Derby Grammar School': /derby grammar|little oaks/i,
  'Clayesmore Marketing': /clayesmore/i, 'Rookwood School': /rookwood/i, 'The Orchard Nursery - Kew': /orchard/i,
  'Collingwood School': /collingwood/i, 'Orchard Day Cambridge': /orchard/i, 'St. Francis Pewsey': /st francis/i,
  "St. Christopher's School": /christopher/i, 'Essendene Lodge': /essendene/i, 'Corner House Nursery': /corner house/i,
  'Wood Wharf Kindergarten': /wood wharf/i, 'St. Francis College': /st francis/i, 'Danesfield Manor School': /danesfield/i,
  'Walton Montessori': /walton montessori/i, 'Royal Hospital School': /royal hospital/i, 'Shebbear College': /shebbear/i
};

function main() {
  var ss = SpreadsheetApp.openByUrl(SHEET_URL);
  var out = { 'Blocklist': [], 'Own brand': [], 'Zero conv': [] };
  var accounts = AdsManagerApp.accounts().get();
  while (accounts.hasNext()) {
    var account = accounts.next();
    AdsManagerApp.select(account);
    var name = account.getName();
    var q = 'SELECT search_term_view.search_term, campaign.id, campaign.name, metrics.cost_micros, metrics.clicks, metrics.conversions ' +
            'FROM search_term_view WHERE segments.date DURING LAST_30_DAYS AND metrics.cost_micros > 0';
    var rows = AdsApp.search(q);
    var toNegate = {};
    while (rows.hasNext()) {
      var r = rows.next();
      var term = r.searchTermView.searchTerm, camp = r.campaign.name;
      var cost = Number(r.metrics.costMicros) / 1e6, conv = Number(r.metrics.conversions), clicks = Number(r.metrics.clicks);
      var line = [name, camp, term, Math.round(cost * 100) / 100, clicks, conv];
      if (/camp|swim/i.test(camp)) continue;
      if (BLOCK.test(term)) {
        out['Blocklist'].push(line);
        (toNegate[r.campaign.id] = toNegate[r.campaign.id] || []).push(term);
      } else if (BRAND[name] && BRAND[name].test(term) && !/brand/i.test(camp)) {
        out['Own brand'].push(line);
      } else if (conv === 0 && cost >= MIN_COST) {
        out['Zero conv'].push(line);
      }
    }
    if (APPLY) {
      Object.keys(toNegate).forEach(function (id) {
        var it = AdsApp.campaigns().withIds([Number(id)]).get();
        if (!it.hasNext()) return; // e.g. Performance Max – add these via a shared list instead
        var c = it.next();
        toNegate[id].forEach(function (t) { c.createNegativeKeyword('[' + t.replace(/[\[\]"]/g, '') + ']'); });
      });
    }
  }
  Object.keys(out).forEach(function (tab) {
    var sh = ss.getSheetByName(tab) || ss.insertSheet(tab);
    sh.clearContents();
    var data = [['Account', 'Campaign', 'Search term', 'Cost £', 'Clicks', 'Conversions']].concat(
      out[tab].sort(function (a, b) { return b[3] - a[3]; }));
    sh.getRange(1, 1, data.length, 6).setValues(data);
  });
  Logger.log('Blocklist ' + out['Blocklist'].length + ', own brand ' + out['Own brand'].length + ', zero conv ' + out['Zero conv'].length);
}

Script 4 · Sister-school overlap

MonthlyLists every town where 2+ ILG accounts pay for the same searchers, with each school’s spend and conversions — the input for town ownership.4-sister-school-overlap.js
4-sister-school-overlap.js
/**
 * ILG – Sister-school overlap (MCC script)
 * Finds towns where two or more ILG accounts are paying for the same searchers.
 * Audit example: New Malden – Westbury £1,446, Orchard Kingston £1,231,
 * The Study £828, Orchard Wimbledon £170 in Jun–Aug.
 * Writes one row per town with each school's spend and conversions, so the
 * cluster can decide which school "owns" the town and exclude it elsewhere.
 * Report-only. Schedule: Monthly, 1st, 06:00.
 */
var SHEET_URL = 'https://docs.google.com/spreadsheets/d/XXXX/edit';
var MIN_COST = 25; // ignore tiny spend per school per town

function main() {
  var towns = {}; // geoTargetConstants/123 -> {acct: [cost, conv]}
  var accounts = AdsManagerApp.accounts().get();
  while (accounts.hasNext()) {
    var account = accounts.next();
    AdsManagerApp.select(account);
    var q = 'SELECT segments.geo_target_city, metrics.cost_micros, metrics.conversions ' +
            'FROM geographic_view WHERE segments.date DURING LAST_30_DAYS AND metrics.cost_micros > 0';
    var rows = AdsApp.search(q);
    while (rows.hasNext()) {
      var r = rows.next();
      var city = r.segments.geoTargetCity;
      if (!city) continue;
      var t = towns[city] = towns[city] || {};
      var a = t[account.getName()] = t[account.getName()] || [0, 0];
      a[0] += Number(r.metrics.costMicros) / 1e6;
      a[1] += Number(r.metrics.conversions);
    }
  }
  var shared = Object.keys(towns).filter(function (c) {
    return Object.keys(towns[c]).filter(function (a) { return towns[c][a][0] >= MIN_COST; }).length >= 2;
  });
  var names = resolveNames(shared);
  var data = [['Town', 'ILG accounts', 'Total £', 'Breakdown (school: £ / conv)']];
  shared.forEach(function (c) {
    var accs = Object.keys(towns[c]).filter(function (a) { return towns[c][a][0] >= MIN_COST; })
      .sort(function (x, y) { return towns[c][y][0] - towns[c][x][0]; });
    var total = accs.reduce(function (s, a) { return s + towns[c][a][0]; }, 0);
    data.push([names[c] || c, accs.length, Math.round(total),
      accs.map(function (a) { return a + ': £' + Math.round(towns[c][a][0]) + ' / ' + towns[c][a][1].toFixed(1); }).join(' | ')]);
  });
  data = [data[0]].concat(data.slice(1).sort(function (a, b) { return b[2] - a[2]; }));
  var sh = SpreadsheetApp.openByUrl(SHEET_URL).getSheets()[0];
  sh.clearContents();
  sh.getRange(1, 1, data.length, 4).setValues(data);
}

function resolveNames(resourceNames) {
  var out = {};
  for (var i = 0; i < resourceNames.length; i += 200) {
    var chunk = resourceNames.slice(i, i + 200).map(function (r) { return "'" + r + "'"; }).join(',');
    if (!chunk) break;
    var rows = AdsApp.search('SELECT geo_target_constant.resource_name, geo_target_constant.name ' +
                             'FROM geo_target_constant WHERE geo_target_constant.resource_name IN (' + chunk + ')');
    while (rows.hasNext()) {
      var g = rows.next().geoTargetConstant;
      out[g.resourceName] = g.name;
    }
  }
  return out;
}

Script 5 · Landing-page health check

DailyLoads every live final URL: errors, a second URL inside the final URL, placeholder text, missing or wrong-school Digistorm form, no tap-to-call link.5-landing-page-health.js
5-landing-page-health.js
/**
 * ILG – Landing-page health check (MCC script)
 * Fetches every live final URL (ads and Performance Max asset groups) once a day
 * and emails anything broken. Audit findings it would have caught:
 *  - Orchard Hampton final URL with a second URL embedded ({ignore}?https://...)
 *  - Orchard Kingston "Book a tour" button pointing to ANOTHER school's Digistorm
 *  - template placeholders left on pages (IMAGE-URL-2, IMAGE-URL-3)
 *  - phone numbers that cannot be tapped on a phone
 * Report-only. Schedule: Daily, 06:30.
 */
var EMAILS = 'admissions-marketing@example.com';
// Optional: the Digistorm account code each Google Ads account should use.
// Any other *.app.digistorm.com code on its pages is flagged.
var DIGISTORM = {
  'Orchard Day Kingston': ['phps-gb-sry-1122'],
  'Orchard Hampton Nursery': ['odnh-gb-eng-1359'],
  'Ruskin House Nursery': ['rhn-gb-lnd-1123'],
  'St Nicholas School': ['snn-gb-lnd-1126']
  // add the rest from the audit's page survey
};

function main() {
  var issues = [];
  var seen = {};
  var accounts = AdsManagerApp.accounts().get();
  while (accounts.hasNext()) {
    var account = accounts.next();
    AdsManagerApp.select(account);
    var name = account.getName();
    var urls = {};
    collect(urls, "SELECT ad_group_ad.ad.final_urls FROM ad_group_ad WHERE ad_group_ad.status = 'ENABLED' " +
                  "AND ad_group.status = 'ENABLED' AND campaign.status = 'ENABLED'", function (r) { return r.adGroupAd.ad.finalUrls; });
    collect(urls, "SELECT asset_group.final_urls FROM asset_group WHERE asset_group.status = 'ENABLED' AND campaign.status = 'ENABLED'",
            function (r) { return r.assetGroup.finalUrls; });
    Object.keys(urls).forEach(function (raw) {
      if (/[?&=]https?:\/\/|https?:\/\/.*https?:\/\//i.test(raw)) issues.push([name, raw, 'Malformed final URL (a second URL inside it)']);
      var url = raw.replace(/\{ignore\}/gi, '').split('?')[0];
      if (seen[url]) return; seen[url] = true;
      var res;
      try { res = UrlFetchApp.fetch(url, { muteHttpExceptions: true, followRedirects: true }); }
      catch (e) { issues.push([name, url, 'Could not load: ' + e.message]); return; }
      var code = res.getResponseCode(), html = res.getContentText();
      if (code >= 400) { issues.push([name, url, 'HTTP ' + code]); return; }
      if (/IMAGE-URL-\d|lorem ipsum|\[school name\]/i.test(html)) issues.push([name, url, 'Template placeholder text on page']);
      var codes = (html.match(/[a-z0-9-]+(?=\.app\.digistorm\.com)/g) || []).filter(function (v, i, a) { return a.indexOf(v) === i; });
      if (!codes.length) issues.push([name, url, 'No Digistorm form or link found']);
      if (DIGISTORM[name]) {
        var wrong = codes.filter(function (c) { return DIGISTORM[name].indexOf(c) < 0; });
        if (wrong.length) issues.push([name, url, 'Links to another Digistorm account: ' + wrong.join(', ')]);
      }
      if (!/href="tel:|clkn\/tel\//i.test(html)) issues.push([name, url, 'No tap-to-call phone link']);
    });
  }
  if (issues.length) {
    MailApp.sendEmail({ to: EMAILS, subject: 'ILG landing pages – ' + issues.length + ' issue(s)',
      htmlBody: '<table border="1" cellpadding="5" style="border-collapse:collapse"><tr><th>Account</th><th>URL</th><th>Issue</th></tr>' +
        issues.map(function (i) { return '<tr><td>' + i[0] + '</td><td>' + i[1] + '</td><td>' + i[2] + '</td></tr>'; }).join('') + '</table>' });
  }
  Logger.log(issues.length + ' issues');
}

function collect(urls, query, pick) {
  var rows = AdsApp.search(query);
  while (rows.hasNext()) {
    (pick(rows.next()) || []).forEach(function (u) { urls[u] = true; });
  }
}

Script 6 · Settings & conversion-goal audit

WeeklySearch Partners/Display on, Maximise clicks, “presence or interest” locations, non-enquiry primary goals and duplicate primary actions. Optional: −100% tablets.6-settings-and-goals-audit.js
6-settings-and-goals-audit.js
/**
 * ILG – Settings & conversion-goal audit (MCC script)
 * Lists, for every account, the settings the audit found leaking money or
 * corrupting bidding:
 *  - Search campaigns with Search Partners or Display network switched on
 *    (audit: £7,286 for 3.5 conversions)
 *  - Search campaigns on "Maximise clicks" (audit: £190 per conversion vs £116)
 *  - Location option "presence OR interest" instead of "presence"
 *  - Primary conversion actions that are not admissions enquiries
 *    (Book Now clicks, swim forms, calls, email clicks, prospectus)
 *  - More than one primary lead-form action (double counting)
 * APPLY_TABLET = true also sets a -100% tablet bid adjustment on enabled Search
 * campaigns (audit: £6,571 on tablets for 6 conversions). Default false.
 * Network settings and conversion goals cannot be changed by scripts – fix those
 * in the interface or Google Ads Editor using the sheet.
 * Schedule: Weekly.
 */
var SHEET_URL = 'https://docs.google.com/spreadsheets/d/XXXX/edit';
var APPLY_TABLET = false;
var NOT_ENQUIRY = /book now|swim|call|email|prospectus|camp|click/i;

function main() {
  var data = [['Account', 'Type', 'Item', 'Detail']];
  var accounts = AdsManagerApp.accounts().get();
  while (accounts.hasNext()) {
    var account = accounts.next();
    AdsManagerApp.select(account);
    var name = account.getName();

    var camps = AdsApp.search(
      'SELECT campaign.id, campaign.name, campaign.bidding_strategy_type, ' +
      'campaign.network_settings.target_search_network, campaign.network_settings.target_content_network, ' +
      'campaign.geo_target_type_setting.positive_geo_target_type ' +
      "FROM campaign WHERE campaign.status = 'ENABLED' AND campaign.advertising_channel_type = 'SEARCH'");
    while (camps.hasNext()) {
      var c = camps.next().campaign;
      if (c.networkSettings.targetSearchNetwork) data.push([name, 'Search Partners ON', c.name, 'Turn off in campaign settings']);
      if (c.networkSettings.targetContentNetwork) data.push([name, 'Display network ON', c.name, 'Turn off in campaign settings']);
      if (c.biddingStrategyType === 'TARGET_SPEND') data.push([name, 'Maximise clicks', c.name, 'Move to Maximise conversions once tracking is fixed']);
      if (c.geoTargetTypeSetting && c.geoTargetTypeSetting.positiveGeoTargetType === 'PRESENCE_OR_INTEREST')
        data.push([name, 'Location: presence or interest', c.name, 'Switch to "Presence" for nursery campaigns']);
      if (APPLY_TABLET) {
        var it = AdsApp.campaigns().withIds([Number(c.id)]).get();
        if (it.hasNext()) {
          var tab = it.next().targeting().platforms().tablet().get();
          if (tab.hasNext()) tab.next().setBidModifier(0);
        }
      }
    }

    var primaryForms = [];
    var conv = AdsApp.search(
      'SELECT conversion_action.name, conversion_action.category, conversion_action.primary_for_goal ' +
      "FROM conversion_action WHERE conversion_action.status = 'ENABLED'");
    while (conv.hasNext()) {
      var a = conv.next().conversionAction;
      if (!a.primaryForGoal) continue;
      if (NOT_ENQUIRY.test(a.name) || a.category === 'PHONE_CALL_LEAD' || a.category === 'CONTACT')
        data.push([name, 'Primary goal is not an enquiry', a.name, a.category + ' – set to Secondary, or give camps their own campaign goal']);
      else primaryForms.push(a.name);
    }
    if (primaryForms.length > 1)
      data.push([name, 'Several primary enquiry actions', primaryForms.join(' | '), 'Keep ONE primary Digistorm enquiry action; others Secondary']);
  }
  var sh = SpreadsheetApp.openByUrl(SHEET_URL).getSheets()[0];
  sh.clearContents();
  sh.getRange(1, 1, data.length, 4).setValues(data);
  Logger.log((data.length - 1) + ' items');
}

Meta automated rules

Meta has no scripts, but Automated Rules (Ads Manager → Rules) do the same job natively. Create these in the main ILG account and each school account. Start with “notify only” for two weeks, then switch R1 and R3 to act.

RuleApply toConditionActionCatches
R1Active ad setsSpend > £150 AND results = 0, last 7 daysNotify → later turn offRookwood-type ad sets optimising for an event that never fires
R2Active ad setsCost per result > 1.5× school target, last 14 days, AND spend > £300NotifySaturation after budget increases
R3Active adsFrequency > 6 AND CTR (link) < 0.8%, last 7 days, AND impressions > 5,000Notify → later turn offFatigued creative (Derby “Jan5”, St Felix, Shebbear)
R4Active ad setsCPM < £1.50 AND CTR (link) < 0.4%, last 3 daysNotifyJunk delivery (Yateley Audience Network, CPM £0.70)
R5Active ad setsLanding-page views ÷ link clicks < 40%, last 7 days, AND link clicks > 100NotifyPixel missing on the landing host (Yateley Prep 0%, Rookwood Prep 2%)

Also in each account: exclude Audience Network, Threads and right-hand column in the ad set placements; never change a budget more than 20% or more than once a week (edits restart learning).

The weekly scorecard

One page, every Monday, by cluster then school. Digistorm is the source of truth; the platforms are only inputs.

MeasureSourceNow (Jul–Aug)90-day targetAction if missed
Cost per Digistorm ad leadSpend ÷ Digistorm Google+Meta leads£214–£223≤£160Move budget within the cluster to the cheapest school
All-source enquiries per clusterDigistormSW London 281/2 monthsNo fall while spend is cutRestore half the cut (audit safeguard)
Platform ÷ Digistorm, per schoolGoogle, Meta, Digistorm0.05–2.6×0.8–1.2×Test-submit the form; check the tag
Missed ad callsScript 212% (St Felix 55%)≤5%Phone cover change at that school
Enquiries matched to a click IDDigistorm hidden fieldsNot captured≥80%Fix the hidden-field pass-through
Quality Score, spend-weightedGoogle81% of spend on QS 1–4≤50%Next page in the rebuild queue
Meta frequency, school accountsMeta5.5–7.9 / month≤4New creative drop
Enquiry → visit rateDigistorm stagesNot recordedRecorded for every schoolAdmissions follow-up coaching

Targets are starting points set from this audit’s May–June performance (£153–£160 per ad lead), not guarantees. Review them after the first 30 days of clean tracking.