How to stop wasting your budget on broken links: Automatic URL monitoring script in Google Ads

Every PPC specialist has encountered this situation at least once: the campaign is running, the budget is being spent, but there are zero conversions. You go to check it out, and there's a 404 page or, even worse, a "Product is out of stock" message with a perfectly working 200 response code.
Standard checkers often only look at the server status code. But modern websites are tricky: a page may open, but be useless for advertising. I solved this problem with a custom script, and today I'm going to share it with you.
Why is a regular URL checker not enough?
Most scripts only check for a 404 error. But that's not enough for effective PPC. Here are some real-life scenarios that standard tools miss:
- Soft 404: The page opens, but displays text such as "Nothing found," "Out of stock," "Product expected," etc.
- Technical work: for example, a message saying "Service temporarily unavailable."
- Empty categories: The page template remains in the online store, but there are no products available.
My script works deeper. It analyzes the content of the page and looks for specific markers (stop phrases) that you set yourself in the script settings.
What can this script do?
- Two-way control: The script not only pauses ad groups with bad links, but also automatically reactivates them when the page is restored.
- Smart content analysis: Before searching for stop words, the script "cleans" unnecessary scripts and styles from the HTML code. This eliminates false positives if a stop word is found somewhere in the technical code of the page.
- Bypassing blocks: The current User-Agent is used so that the site's servers do not accept the script's request as suspicious bot activity.
- Status monitoring: If you have a huge account, the script will not time out. It remembers where it left off and continues checking the next time it is launched.
- Transparent logging: All actions are recorded in Google Sheets — you can always see when, why, and which group was stopped or enabled.
Script setup instructions
Step 1: Prepare the spreadsheet
Copy the spreadsheet to your Google Drive and copy its URL.
Step 2: Configure the script
In the script code (below), you need to edit the SETTINGS block:
SPREADSHEET_URL: Insert a link to your spreadsheet. First, copy the table specified in the script code to your Google Drive.MARKER_TEXTS: A list of phrases that, when found, should stop the group (for example, "Out of stock, " "Service temporarily unavailable," etc. — you can create your own list of markers for a specific project).CAMPAIGN_NAME_CONTAINS_OR: If you want to check only specific campaigns rather than the entire account, enter parts of their names. The script will search for campaigns according to the rule "Name contains [...]". That is, in the script code, you need to replace the text "Your-campaign-name-or-part-of-the-name" with part of the name of the campaign(s). If you want the script to check all campaigns in the advertising account, leave the square brackets [ ] empty.
Step 3: Launch
Install the script in your Google Ads account, set a schedule (at least once a day), and click "Run."
Script code:
javascript// Copyright 2026, Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // https://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // ------------------------------------------------ // Developed by: Artem Motin // Project: Quantum — PPC Automation Workspace // Official Website: https://www.myquantum.app // ------------------------------------------------ // // ------------------- SETTINGS ------------------- // const SPREADSHEET_URL = "https://docs.google.com/spreadsheets/d/1wUS4v32Hh9Y_C-Y_NvV47k_nLwL4_fpcG-5HWVaXZ_c/edit"; const PAUSED_LOG_SHEET_NAME = "Paused Log"; const ENABLED_LOG_SHEET_NAME = "Enabled Log"; const MARKER_TEXTS = ['Немає в наявності', 'Нет в наличии', 'Out of stock']; /** * (Optional) To make the script run only on specific campaigns. * The script will check campaigns where the name contains ANY of the phrases below. * To specify multiple conditions: ["Search", "Brand", "Shopping"] * To specify a single condition: ["Search"] * To check all campaigns, leave the array empty: [] */ const CAMPAIGN_NAME_CONTAINS_OR = ["Your-campaign-name-or-part-of-the-name"]; const LABEL_NAME = "Auto-Paused by URL Checker"; // ------------------- END OF SETTINGS ------------------- // /** * Main function that orchestrates the script's execution using a state machine. * It processes tasks sequentially and saves progress to handle timeouts. */ function main() { ensureLabelExists(LABEL_NAME); const properties = PropertiesService.getScriptProperties(); let state = properties.getProperty('currentScriptState') || 'REENABLE'; while(AdsApp.getExecutionInfo().getRemainingTime() > 120) { console.log(`Current state: ${state}`); if (state === 'REENABLE') { const result = reenableGoodAdGroups(); if (result.isFinished) { state = 'PAUSE'; properties.setProperty('currentScriptState', 'PAUSE'); console.log("Re-enable step finished. Transitioning to Pause step in the same run."); } else { console.log("Re-enable step did not finish. Will resume on the next run."); break; } } if (state === 'PAUSE') { const result = pauseBadAdGroups(); if (result.isFinished) { state = 'FINISHED'; properties.deleteProperty('currentScriptState'); console.log("Pause step finished. All tasks completed for this cycle."); } else { console.log("Pause step did not finish. Will resume on the next run."); } break; } if (state === 'FINISHED') { break; } } console.log("Script execution finished for this run."); } function pauseBadAdGroups() { const properties = PropertiesService.getScriptProperties(); const pausedSheet = getSheet(SPREADSHEET_URL, PAUSED_LOG_SHEET_NAME, ["Date", "Campaign", "Ad Group", "Broken URL", "Reason"]); const urlsToAdGroups = getUrlsToAdGroupsMapping(); const urls = Object.keys(urlsToAdGroups); const pausedAdGroupIdsInThisRun = new Set(); const startIndex = parseInt(properties.getProperty('pauseProgressIndex') || '0', 10); if(urls.length === 0) { console.log("No ads found to check within the specified campaigns."); properties.deleteProperty('pauseProgressIndex'); return { isFinished: true }; } if (startIndex === 0) console.log(`Found ${urls.length} unique URLs to check.`); else console.log(`Resuming pause step. Starting from URL #${startIndex + 1} of ${urls.length}.`); let i; for (i = startIndex; i < urls.length; i++) { if (AdsApp.getExecutionInfo().getRemainingTime() < 120) { properties.setProperty('pauseProgressIndex', i); return { isFinished: false }; } const url = urls[i]; console.log(`Checking URL ${i + 1}/${urls.length}: ${url}`); const checkResult = isUrlBroken(url); if (checkResult.isBroken) { const adGroupsData = urlsToAdGroups[url]; for (const adGroupData of adGroupsData) { if (pausedAdGroupIdsInThisRun.has(adGroupData.adGroupId)) continue; const adGroup = getAdGroupById(adGroupData.campaignId, adGroupData.adGroupId); if (adGroup && !adGroup.isPaused()) { adGroup.applyLabel(LABEL_NAME); adGroup.pause(); console.log(`Ad group "${adGroup.getName()}" has been paused.`); pausedAdGroupIdsInThisRun.add(adGroupData.adGroupId); pausedSheet.appendRow([new Date(), adGroup.getCampaign().getName(), adGroup.getName(), url, checkResult.reason]); } } } } if (i >= urls.length) { console.log("All URLs checked. Resetting progress for this step."); properties.deleteProperty('pauseProgressIndex'); return { isFinished: true }; } return { isFinished: false }; } function reenableGoodAdGroups() { const properties = PropertiesService.getScriptProperties(); const enabledSheet = getSheet(SPREADSHEET_URL, ENABLED_LOG_SHEET_NAME, ["Date", "Campaign", "Ad Group", "Checked URL"]); const labelIterator = AdsApp.labels().withCondition(`Name = '${LABEL_NAME}'`).get(); if (!labelIterator.hasNext()) { console.log(`Label "${LABEL_NAME}" not found. Skipping re-enable step.`); return { isFinished: true }; } const label = labelIterator.next(); const campaignIdsToFilter = getMatchingCampaignIds(); const adGroupSelector = label.adGroups().withCondition("Status = 'PAUSED'"); if (campaignIdsToFilter.length > 0) { adGroupSelector.withCondition(`CampaignId IN [${campaignIdsToFilter.join(',')}]`); } else if (CAMPAIGN_NAME_CONTAINS_OR && CAMPAIGN_NAME_CONTAINS_OR.length > 0) { // If filter is set but no campaigns match, do nothing. console.log("No campaigns found matching the name criteria for the re-enable step. Skipping."); return { isFinished: true }; } const adGroupIterator = adGroupSelector.get(); const adGroupIds = []; while(adGroupIterator.hasNext()){ adGroupIds.push(adGroupIterator.next().getId()); } if(adGroupIds.length === 0) { console.log("No previously paused ad groups found to check within the specified campaigns."); properties.deleteProperty('reenableProgressIndex'); return { isFinished: true }; } let startIndex = parseInt(properties.getProperty('reenableProgressIndex') || '0', 10); if (startIndex >= adGroupIds.length) { console.log(`Saved index (${startIndex}) is out of bounds (total groups: ${adGroupIds.length}). Resetting progress to 0.`); startIndex = 0; } if (startIndex === 0) console.log(`Found ${adGroupIds.length} previously paused ad groups to check.`); else console.log(`Resuming re-enable step. Starting from ad group #${startIndex + 1} of ${adGroupIds.length}.`); let i; for (i = startIndex; i < adGroupIds.length; i++) { if (AdsApp.getExecutionInfo().getRemainingTime() < 120) { properties.setProperty('reenableProgressIndex', i); return { isFinished: false }; } const adGroup = AdsApp.adGroups().withIds([adGroupIds[i]]).get().next(); console.log(`Checking paused group ${i+1}/${adGroupIds.length}: "${adGroup.getName()}"`); const ads = adGroup.ads().withCondition("Status IN ['ENABLED', 'PAUSED']").get(); if (!ads.hasNext()) { adGroup.enable(); safelyRemoveLabel(adGroup, LABEL_NAME); console.log(`Ad group "${adGroup.getName()}" has been enabled (it contained no ads to check).`); continue; } let isSafeToEnable = true; let lastCheckedUrl = ""; const urlsInGroup = new Set(); while(ads.hasNext()){ const ad = ads.next(); if(ad.urls().getFinalUrl()) urlsInGroup.add(ad.urls().getFinalUrl()); } for (const url of urlsInGroup) { lastCheckedUrl = url; const checkResult = isUrlBroken(url); if (checkResult.isBroken) { // FIX: Логируем причину отказа console.log(`[SKIP] Group "${adGroup.getName()}" NOT enabled. URL: ${url} | Reason: ${checkResult.reason}`); isSafeToEnable = false; break; } } if (isSafeToEnable) { adGroup.enable(); safelyRemoveLabel(adGroup, LABEL_NAME); console.log(`Ad group "${adGroup.getName()}" has been re-enabled.`); enabledSheet.appendRow([new Date(), adGroup.getCampaign().getName(), adGroup.getName(), lastCheckedUrl || "N/A"]); } } if(i >= adGroupIds.length){ console.log("All paused groups checked. Resetting progress for this step."); properties.deleteProperty('reenableProgressIndex'); return { isFinished: true }; } return { isFinished: false }; } // --- Helper Functions --- /** * Checks if a given URL is broken, with a retry mechanism for server errors (5xx). * @param {string} url - The URL to check. * @return {{isBroken: boolean, reason: string}} - An object with the check result. */ function isUrlBroken(url) { const RETRY_COUNT = 3; const RETRY_DELAY_MS = 2000; for (let i = 0; i < RETRY_COUNT; i++) { try { if (i > 0) { console.log(`Retrying URL (${i + 1}/${RETRY_COUNT}): ${url}`); Utilities.sleep(RETRY_DELAY_MS); } const response = UrlFetchApp.fetch(url, { muteHttpExceptions: true, headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' } }); const responseCode = response.getResponseCode(); if (responseCode >= 400 && responseCode < 500) return { isBroken: true, reason: `HTTP Status ${responseCode}` }; if (responseCode >= 500) { if (i < RETRY_COUNT - 1) continue; else return { isBroken: true, reason: `HTTP Status ${responseCode} (after ${RETRY_COUNT} attempts)` }; } let rawContent = response.getContentText(); let cleanContent = rawContent.replace(/<script[^>]*>([\s\S]*?)<\/script>/gi, " "); cleanContent = cleanContent.replace(/<style[^>]*>([\s\S]*?)<\/style>/gi, " "); const contentLowerCase = cleanContent.toLowerCase(); for (const marker of MARKER_TEXTS) { if (contentLowerCase.includes(marker.toLowerCase())) { return { isBroken: true, reason: `Found text: "${marker}"` }; } } return { isBroken: false, reason: '' }; } catch (e) { if (i < RETRY_COUNT - 1) continue; return { isBroken: true, reason: `URL fetch error: ${e.message}` }; } } } function safelyRemoveLabel(entity, labelName) { const labelIterator = entity.labels().withCondition(`Name = '${labelName}'`).get(); if (labelIterator.hasNext()) entity.removeLabel(labelName); else console.warn(`Attempted to remove label "${labelName}" from "${entity.getName()}", but it was not found.`); } /** * Gets a list of campaign IDs that match the names in the settings. * @return {string[]} - An array of campaign IDs. */ function getMatchingCampaignIds() { const campaignIds = []; if (typeof CAMPAIGN_NAME_CONTAINS_OR !== 'undefined' && CAMPAIGN_NAME_CONTAINS_OR && CAMPAIGN_NAME_CONTAINS_OR.length > 0) { const campaignIterator = AdsApp.campaigns() .withCondition("Status = 'ENABLED'") .get(); while (campaignIterator.hasNext()) { const campaign = campaignIterator.next(); const campaignName = campaign.getName(); const matches = CAMPAIGN_NAME_CONTAINS_OR.some(namePart => campaignName.includes(namePart)); if (matches) { campaignIds.push(campaign.getId()); } } } return campaignIds; } function getUrlsToAdGroupsMapping() { const mapping = {}; const campaignIdsToFilter = getMatchingCampaignIds(); if (CAMPAIGN_NAME_CONTAINS_OR && CAMPAIGN_NAME_CONTAINS_OR.length > 0 && campaignIdsToFilter.length === 0) { console.log("No campaigns found matching the name criteria. Skipping ad check."); return {}; } const adSelector = AdsApp.ads() .withCondition("Status IN ['ENABLED', 'PAUSED']") .withCondition("AdGroupStatus = 'ENABLED'") .withCondition("CampaignStatus = 'ENABLED'"); if (campaignIdsToFilter.length > 0) { adSelector.withCondition(`CampaignId IN [${campaignIdsToFilter.join(',')}]`); } const ads = adSelector.get(); while (ads.hasNext()) { const ad = ads.next(); const url = ad.urls().getFinalUrl(); if (url) { if (!mapping[url]) mapping[url] = []; const adGroup = ad.getAdGroup(); if (!mapping[url].some(item => item.adGroupId === adGroup.getId())) { mapping[url].push({adGroupId: adGroup.getId(), campaignId: adGroup.getCampaign().getId()}); } } } return mapping; } function getSheet(spreadsheetUrl, sheetName, headers) { const spreadsheet = SpreadsheetApp.openByUrl(spreadsheetUrl); let sheet = spreadsheet.getSheetByName(sheetName); if (!sheet) { sheet = spreadsheet.insertSheet(sheetName); if (headers && headers.length > 0) sheet.appendRow(headers); } return sheet; } function ensureLabelExists(labelName) { if (!AdsApp.labels().withCondition(`Name = '${labelName}'`).get().hasNext()) { AdsApp.createLabel(labelName, "For ad groups paused by the URL checker script"); console.log(`Created label: "${labelName}"`); } } function getAdGroupById(campaignId, adGroupId) { const iterator = AdsApp.adGroups().withCondition(`CampaignId = ${campaignId}`).withCondition(`Id = ${adGroupId}`).get(); if (iterator.hasNext()) return iterator.next(); return null; }
Conclusion
This script is your personal "night watchman" that saves hours of manual checking and hundreds of dollars of client budget. It is simple, reliable, and does exactly what you need.
If you like this approach to automation and want to simplify your work with Google Ads even more, try Quantum. It's a SaaS service that I'm developing so that PPC specialists can generate ready-made campaigns almost automatically, without wasting time on routine tasks.
Do you have questions about how the script works? Write in the comments to the post on our Telegram channel, and we'll figure it out together!
