Назад к статьям

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

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

Каждый PPC-специалист хоть раз сталкивался с ситуацией: кампания работает, бюджет тратится, а конверсий ноль. Заходишь проверить - а там страница 404 или, что еще хуже, надпись “Товара нет в наличии” при вполне рабочем коде ответа 200.

Стандартные чекеры часто смотрят только на статус-код сервера. Но современные сайты коварны: страница может открываться, но быть бесполезной для рекламы. Я решил эту проблему с помощью кастомного скрипта и сегодня поделюсь им с вами.

Почему обычного URL-чекера недостаточно?

Большинство скриптов проверяют только наличие ошибки 404. Но для эффективного PPC этого мало. Вот реальные сценарии, которые «проглатывают» стандартные инструменты:

  • Мягкая 404: Страница открывается, но на ней текст “Ничего не найдено”, “Нет в наличии”, “Товар ожидается” и т.д.
  • Технические работы: например, сообщение “Сервис временно недоступен”.
  • Пустые категории: В интернет-магазине остался шаблон страницы, но товаров в наличии нет.

Мой скрипт работает глубже. Он анализирует содержимое страницы и ищет конкретные маркеры (стоп-фразы), которые вы задаете сами в настройках скрипта.

Что умеет этот скрипт?

  1. Двусторонний контроль: Скрипт не только ставит на паузу группы объявлений с плохими ссылками, но и автоматически включает их обратно, когда страница восстанавливается.
  2. Умный анализ контента: Перед поиском стоп-слов скрипт “вычищает” из HTML-кода лишние скрипты и стили. Это исключает ложные срабатывания, если стоп-слово встретилось где-то в техническом коде страницы. Также обрабатываются стандартные ошибки 404, 503 и т.д.
  3. Обход блокировок: Используется актуальный User-Agent, чтобы серверы сайта не принимали запрос скрипта за подозрительную активность бота.
  4. Контроль состояний: Если у вас огромный аккаунт, скрипт не упадет по таймауту. Он запоминает, на чем остановился, и продолжит проверку при следующем запуске.
  5. Прозрачное логирование: Все действия записываются в 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-канале, разберемся вместе!