Как перестать сливать бюджет на “битые” ссылки: Скрипт автоматического мониторинга URL в Google Ads

Каждый PPC-специалист хоть раз сталкивался с ситуацией: кампания работает, бюджет тратится, а конверсий ноль. Заходишь проверить - а там страница 404 или, что еще хуже, надпись “Товара нет в наличии” при вполне рабочем коде ответа 200.
Стандартные чекеры часто смотрят только на статус-код сервера. Но современные сайты коварны: страница может открываться, но быть бесполезной для рекламы. Я решил эту проблему с помощью кастомного скрипта и сегодня поделюсь им с вами.
Почему обычного URL-чекера недостаточно?
Большинство скриптов проверяют только наличие ошибки 404. Но для эффективного PPC этого мало. Вот реальные сценарии, которые «проглатывают» стандартные инструменты:
- Мягкая 404: Страница открывается, но на ней текст “Ничего не найдено”, “Нет в наличии”, “Товар ожидается” и т.д.
- Технические работы: например, сообщение “Сервис временно недоступен”.
- Пустые категории: В интернет-магазине остался шаблон страницы, но товаров в наличии нет.
Мой скрипт работает глубже. Он анализирует содержимое страницы и ищет конкретные маркеры (стоп-фразы), которые вы задаете сами в настройках скрипта.
Что умеет этот скрипт?
- Двусторонний контроль: Скрипт не только ставит на паузу группы объявлений с плохими ссылками, но и автоматически включает их обратно, когда страница восстанавливается.
- Умный анализ контента: Перед поиском стоп-слов скрипт “вычищает” из HTML-кода лишние скрипты и стили. Это исключает ложные срабатывания, если стоп-слово встретилось где-то в техническом коде страницы. Также обрабатываются стандартные ошибки 404, 503 и т.д.
- Обход блокировок: Используется актуальный User-Agent, чтобы серверы сайта не принимали запрос скрипта за подозрительную активность бота.
- Контроль состояний: Если у вас огромный аккаунт, скрипт не упадет по таймауту. Он запоминает, на чем остановился, и продолжит проверку при следующем запуске.
- Прозрачное логирование: Все действия записываются в Google Таблицу - вы всегда видите, когда, почему и какая группа была остановлена или включена.
Инструкция по настройке скрипта
Шаг 1: Подготовка таблицы
Скопируйте на свой Google Диск таблицу и скопируйте её URL.
Шаг 2: Настройка скрипта
В коде скрипта (ниже) вам нужно отредактировать блок SETTINGS:
SPREADSHEET_URL: Вставьте ссылку на вашу таблицу (предварительно скопируйте таблицу, указанную в коде скрипта, на свой Google Диск).MARKER_TEXTS: Список фраз, при нахождении которых группу нужно остановить (например, 'Нет в наличии', 'Сервис временно недоступен' и т.д. - точный список маркеров вы формируете самостоятельно, под конкретный проект).CAMPAIGN_NAME_CONTAINS_OR: Если хотите проверять не весь аккаунт, а только конкретные кампании - впишите части их названий. Скрипт будет искать кампании по правилу “Название содержит […]”. То есть, в коде скрипта вам нужно заменить текст “Your-campaign-name-or-part-of-the-name” на часть названия кампании/кампаний. Если вы хотите, чтобы скрипт проверял все кампании в рекламном аккаунте - оставьте пустыми квадратные скобки [ ].
Шаг 3: Запуск
Установите скрипт в аккаунте Google Ads, задайте расписание (как минимум - раз в сутки) и нажмите “Выполнить”.
Код скрипта:
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; }
Заключение
Этот скрипт - ваш личный “ночной сторож”, который экономит часы ручной проверки и сотни долларов клиентского бюджета. Он простой, надежный и делает именно то, что нужно практику.
Если вам близок такой подход к автоматизации и вы хотите еще сильнее упростить работу с Google Ads - попробуйте Quantum. Это SaaS-сервис, который я развиваю, чтобы PPC-специалисты могли генерировать готовые кампании почти автоматически, не тратя время на рутину.
Есть вопросы по работе скрипта? Пишите в комментариях к посту в нашем Tegegram-канале, разберемся вместе!
