Last active
February 12, 2026 22:01
-
-
Save vhsu/3004945614b46d227275b5b511b71b81 to your computer and use it in GitHub Desktop.
Google Grants Report Google Ads Script By Suisseo (Single Account Script)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /********************************************************************************************************************** | |
| 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_ |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Updated some stuff : Please update