Skip to content

Instantly share code, notes, and snippets.

@vhsu
Last active February 12, 2026 22:01
Show Gist options
  • Select an option

  • Save vhsu/3004945614b46d227275b5b511b71b81 to your computer and use it in GitHub Desktop.

Select an option

Save vhsu/3004945614b46d227275b5b511b71b81 to your computer and use it in GitHub Desktop.
Google Grants Report Google Ads Script By Suisseo (Single Account Script)
/**********************************************************************************************************************
This Google Ads script checks a single Google grants account for mandatory requirements and logs the results in a Google Spreadsheet
UPDATED : 12.02.2026 : Typo fix
Author : Suisseo (Vincent Hsu)
More Info : https://www.suisseo.ch/en/blog/google-ad-grants-script/
1. Detect if Campaigns are set to 'maximize conversion' or 'maximize conversion value' to allow bids higher that 2 dollars
2. Detect if each Campaign has at least 2 active ad groups with at least 2 active text (or at least 1 RSA)
3. Detect if each account has at least 2 active Sitelinks
4. Detect if each campaign has geo-targeting
5. Detect Keywords that have a quality score under 3
6. Detect single keywords that are not branded or not in the authorized list
**********************************************************************************************************************/
// The url of the Spreadsheet
// Copy this template Google Spreadsheet in your Google Drive account : https://docs.google.com/spreadsheets/d/1rYif4Z9cTF1WmCRRl2w9vIOFy_ivs22_UpRP_qYHv08/copy
// You can change the name of the Spreadsheet, add Tabs, but do not change the names of the tabs in your Spreadsheet.
// Save the url and paste it below
var SPREADSHEETURL = 'https://docs.google.com/spreadsheets/d/YOURSPREADSHEETKEY/edit#gid=0';
// Array of e-mails to which a notification should be sent every time the report is executed, comma separated
var ALERTMAILS = ['[email protected]'];
// List of branded single keywords that should not be taken into account (any single keyword that contains any of these will not be reported), comma separated
var BRANDEDKEYWORDS = ['YOURBRAND', 'ANOTHERBRANDEDKEYWORD'];
// Include paused campaigns, ad groups and keywords in the reports can be set to true or false
var INCLUDEPAUSED = false;
var authorizedOneWordersArray = getAuthorizedSingleWords();
function main() {
try {
runGrantsCheck();
} catch (e) {
console.error("Critical Error in main execution: " + e);
if (ALERTMAILS && ALERTMAILS[0].indexOf('@') > -1) {
MailApp.sendEmail(ALERTMAILS.join(','), "Grants Script Failed", "The script encountered a critical error: " + e);
}
}
}
function runGrantsCheck() {
const account = AdsApp.currentAccount().getCustomerId();
const accountName = AdsApp.currentAccount().getName();
// Run Checks
const campaignSums = checkCampaigns(SPREADSHEETURL);
const lowQSSum = getLowQualityKeywords(SPREADSHEETURL);
const oneWorderSum = getOneWorders(SPREADSHEETURL, BRANDEDKEYWORDS);
const ctr30Days = getAccountCtr(SPREADSHEETURL);
const totalCost30Days = AdsApp.currentAccount().getStatsFor("LAST_30_DAYS").getCost();
// Write Overview to Spreadsheet
const access = new SpreadsheetAccess(SPREADSHEETURL, 'Abstract');
access.clearAll();
access.writeRows([
['Single keywords', 'Keywords with a quality \nscore smaller than 3', 'Campaigns with less \nthan 2 ad groups', 'Campaigns with \nno geo-targeting', 'Ad groups with less \nthan 2 active ads & no RSA', 'Campaigns with less \nthan 2 sitelinks', 'CTR 30 days'],
[oneWorderSum, lowQSSum, campaignSums[0], campaignSums[1], campaignSums[3], campaignSums[2], ctr30Days],
['Last Run: ' + new Date(), '', '', '', '', '', '']
], 1, 1);
access.formatRows([
['#00ffff', '#00ffff', '#00ffff', '#00ffff', '#00ffff', '#00ffff', '#00ffff'],
['#ffff00', '#ffff00', '#ffff00', '#ffff00', '#ffff00', '#ffff00', '#ffff00']
], 1, 1);
// Prepare Email
const emailMessageTitle = "Grants Report - " + accountName + " (" + account + ")";
let emailMessageBody = "Your Google Grants report for " + accountName + " (" + account + ") is ready.\n\n";
emailMessageBody += "Summary:\n";
emailMessageBody += "- CTR (Last 30 Days): " + Math.round(ctr30Days * 100) / 100 + "% " + (ctr30Days < 5 ? "(WARNING: Below 5%)" : "(Good)") + "\n";
emailMessageBody += "- Spend (Last 30 Days): $" + Math.round(totalCost30Days * 100) / 100 + "\n\n";
emailMessageBody += "Issues Found:\n";
emailMessageBody += "- Single Word Keywords: " + oneWorderSum + "\n";
emailMessageBody += "- Low Quality Score (<3): " + lowQSSum + "\n";
emailMessageBody += "- Campaigns with <2 Ad Groups: " + campaignSums[0] + "\n";
emailMessageBody += "- Campaigns with No Geo-Targeting: " + campaignSums[1] + "\n";
emailMessageBody += "- Ad Groups with Insufficient Ads: " + campaignSums[3] + "\n";
emailMessageBody += "- Campaigns with <2 Sitelinks: " + campaignSums[2] + "\n\n";
// FIXED: Removed stray 'F' character
emailMessageBody += "Full details here: \n" + SPREADSHEETURL;
// Send Email
if (ALERTMAILS && ALERTMAILS.length > 0 && ALERTMAILS[0].indexOf('@') > -1) {
sendSimpleTextEmail(emailMessageTitle, ALERTMAILS, emailMessageBody);
}
}
function checkCampaigns(SpreadsheetUrl) {
const campaignTabName = 'Campaign Data';
const adGroupTabName = 'AdGroup Data';
let campaignRows = [];
let inc = [0, 0, 0, 0]; // [AdGroups<2, Geo<1, Sitelinks<2, Ads<2]
let campaignFormatRows = [];
let adGroupRows = [];
let adGroupFormatRows = [];
let status = "Status = ENABLED";
if (INCLUDEPAUSED == true) status = "Status != REMOVED";
// Pre-fetch Sitelink Assets via GAQL (v21)
const campaignSitelinkCounts = getCampaignSitelinkCounts();
const totalAccountSitelinks = getAccountSitelinkCount();
campaignRows.push(['CAMPAIGN NAME', 'BIDDING STRATEGY', "CONVERSIONS 30 DAYS", 'ACTIVE AD GROUPS', 'TARGETED LOCATIONS', 'CAMPAIGN SITELINKS', 'ACCOUNT SITELINKS']);
adGroupRows.push(['CAMPAIGN NAME', 'AD GROUP NAME', 'ENABLED ADS']);
const campaignIterator = AdsApp.campaigns()
.withCondition(status)
.forDateRange("LAST_30_DAYS")
.get();
while (campaignIterator.hasNext()) {
const currentCampaign = campaignIterator.next();
const campaignName = currentCampaign.getName();
const campaignId = currentCampaign.getId();
const campaignBiddingStrategy = currentCampaign.getBiddingStrategyType();
const campaignConversions = currentCampaign.getStatsFor('LAST_30_DAYS').getConversions();
// Check Ad Groups
const adGroupIterator = currentCampaign.adGroups().withCondition("Status = ENABLED").get();
const totalNumAdGroups = adGroupIterator.totalNumEntities();
// Check Geo Targeting
const totalNumTargetedLocation = currentCampaign.targeting().targetedLocations().get().totalNumEntities();
const totalNumTargetedProximity = currentCampaign.targeting().targetedProximities().get().totalNumEntities();
const totalGeo = totalNumTargetedLocation + totalNumTargetedProximity;
// Check Sitelinks (From Map)
const totalCampaignSitelinks = campaignSitelinkCounts[campaignId] || 0;
// Formatting
let campaignBiddingColor = (campaignBiddingStrategy == 'MAXIMIZE_CONVERSIONS' || campaignBiddingStrategy == 'MAXIMIZE_CONVERSION_VALUE') ? '#d9ead3' : '#f4cccc';
campaignRows.push([campaignName, campaignBiddingStrategy, campaignConversions, totalNumAdGroups, totalGeo, totalCampaignSitelinks, totalAccountSitelinks]);
campaignFormatRows.push([' ', campaignBiddingColor, '', totalNumAdGroups < 2 ? '#f4cccc' : '#d9ead3', totalGeo < 1 ? '#f4cccc' : '#d9ead3', totalCampaignSitelinks < 2 ? '#f4cccc' : '#d9ead3', totalAccountSitelinks < 2 ? '#f4cccc' : '#d9ead3']);
if (totalNumAdGroups < 2) inc[0]++;
if (totalGeo < 1) inc[1]++;
// Policy: Needs 2 sitelinks at campaign OR account level
if (totalCampaignSitelinks < 2 && totalAccountSitelinks < 2) inc[2]++;
// Check Ads inside Ad Groups
while (adGroupIterator.hasNext()) {
const currentAdGroup = adGroupIterator.next();
// Count standard Enabled Ads
const adsIterator = currentAdGroup.ads().withCondition("Status = ENABLED").get();
// Count Enabled RSAs specifically
const adsIteratorRSA = currentAdGroup.ads()
.withCondition("Status = ENABLED")
.withCondition("Type = RESPONSIVE_SEARCH_AD")
.get();
// Rule: At least 2 active ads OR 1 active RSA
if (adsIterator.totalNumEntities() < 2 && adsIteratorRSA.totalNumEntities() == 0) {
inc[3]++;
adGroupRows.push([campaignName, currentAdGroup.getName(), adsIterator.totalNumEntities()]);
adGroupFormatRows.push(['', '', '#f4cccc']);
}
}
}
let access = new SpreadsheetAccess(SpreadsheetUrl, campaignTabName);
access.clearAll();
access.writeRows(campaignRows, 1, 1);
access.formatRows(campaignFormatRows, 2, 1);
access.freezeFirstRow();
access = new SpreadsheetAccess(SpreadsheetUrl, adGroupTabName);
access.clearAll();
access.writeRows(adGroupRows, 1, 1);
access.formatRows(adGroupFormatRows, 2, 1);
access.freezeFirstRow();
return inc;
}
function getOneWorders(SpreadsheetUrl, branded) {
let incW = 0;
const singleWordTabName = 'Single Word';
let singleWordRows = [];
let singleWordFormatRows = [];
let statusClause = "WHERE ad_group.status = 'ENABLED' AND campaign.status = 'ENABLED' AND ad_group_criterion.status = 'ENABLED' AND ad_group_criterion.negative = false ";
if (INCLUDEPAUSED) {
statusClause = "WHERE ad_group.status != 'REMOVED' AND campaign.status != 'REMOVED' AND ad_group_criterion.status != 'REMOVED' AND ad_group_criterion.negative = false ";
}
singleWordRows.push(['CAMPAIGN NAME', 'AD GROUP NAME', 'KEYWORD']);
const report = AdsApp.report(
"SELECT ad_group_criterion.keyword.text, campaign.name, ad_group.name, campaign.status, ad_group.status, ad_group_criterion.status " +
"FROM keyword_view " +
statusClause +
"AND segments.date during LAST_30_DAYS", {
apiVersion: 'v21'
}
);
const rows = report.rows();
while (rows.hasNext()) {
const row = rows.next();
const originalKw = row['ad_group_criterion.keyword.text'];
// Strip match types and special chars for word counting
let cleanKw = originalKw.replace(/[|&|\/|\\|#|,|+|(|)|\-|$|~|%|.|'|"|:|*|?|<|>|{|}|\[|\]]/g, ' ').trim();
let wordCount = cleanKw.split(/\s+/).length;
if (wordCount === 1) {
let authorized = false;
// 1. Check Global Allowed List
for (let i = 0; i < authorizedOneWordersArray.length; i++) {
if (authorizedOneWordersArray[i][0] && authorizedOneWordersArray[i][0].toLowerCase() === cleanKw.toLowerCase()) {
authorized = true;
break;
}
}
// 2. Check Custom Branded List
if (!authorized && branded) {
for (let p = 0; p < branded.length; p++) {
if (cleanKw.toLowerCase().indexOf(branded[p].toLowerCase()) !== -1) {
authorized = true;
break;
}
}
}
if (!authorized) {
singleWordRows.push([row['campaign.name'], row['ad_
@vhsu
Copy link
Author

vhsu commented Feb 23, 2024

Updated some stuff : Please update

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment