Linki w reklamach Google Ads bardzo często są traktowane jako element „ustawiony raz i zapomniany”. W praktyce to jeden z częstszych powodów cichych problemów w kampaniach – budżet się wydaje, reklamy są aktywne, a część ruchu trafia na strony, które już nie istnieją, zostały przekierowane albo zwracają błąd serwera. Szczególnie często dzieje się to w kampaniach typu long tail oraz w e-commerce, gdzie adresy produktów zmieniają się dynamicznie, a klient nie zawsze informuje o modyfikacjach w strukturze witryny. Google Ads nie wychwytuje wszystkich takich sytuacji automatycznie, dlatego warto mieć dodatkowy mechanizm kontroli. W tym artykule pokażę Ci rozwiązanie oparte na skryptach Google Ads, które automatycznie sprawdzają linki w aktywnych reklamach i rozszerzeniach, wykrywają błędy 301, 404 i 500 oraz raportują zmiany w jednym miejscu. Opiszę zarówno wersję działającą na pojedynczym koncie Google Ads, jak i wariant przeznaczony dla Mojego Centrum Klienta, dzięki czemu można w prosty sposób monitorować linki nawet w dużych strukturach kont. Szczegóły poniżej.

Dlaczego kontrola linków w Google Ads jest problemem?
Google Ads nie wychwytuje wszystkich problemów z URL-ami automatycznie. W szczególności dotyczy to:
- przekierowań 301 (kampania działa, ale landing page się zmienił i prawdopodobnie ucinany jest parametr GCLID, który odpowiada za automatyczne tagowanie),
- zmian adresów produktów w e-commerce,
- długiego ogona (long tail), gdzie jest wiele reklam prowadzących do pojedynczych podstron,
- sytuacji, w których klient zmienia strukturę strony bez informowania specjalisty.
Efekt? Budżet się wydaje, kampanie formalnie są aktywne, ale część ruchu trafia w złe miejsce.
Co robi opisany skrypt?
Skrypt został zaprojektowany jako narzędzie kontrolne, a nie jednorazowy audyt.
Sprawdza:
- linki w aktywnych reklamach (tylko włączone kampanie i grupy reklam),
- linki w rozszerzeniach reklam (sitelinks):
- na poziomie kampanii,
- na poziomie grup reklam.
Wykrywa i raportuje:
- 301 – przekierowania i zmianę adresu URL,
- 404 – brak strony,
- 500 – błędy serwera.
Pomija:
- 200 – poprawne odpowiedzi,
- wyłączone kampanie, grupy reklam i reklamy.
Dlaczego to ważne w kampaniach long tail?
W kampaniach opartych o dużą liczbę zapytań i produktów często jest kilkadziesiąt lub kilkaset różnych URL-i, część produktów znika z oferty, część zmienia adres (SEO, migracje, zmiany CMS), a Google Ads nie zawsze oznacza to jako błąd.
Skrypt pozwala szybko wykryć, które linki przestały być aktualne, zobaczyć, czy zmienił się adres docelowy, zareagować zanim problem wpłynie na wyniki kampanii.
Raportowanie i kontrola
Wyniki działania skryptu są zapisywane w Arkuszu Google, gdzie dla każdego problemu masz m.in.: nazwę konta i ID, nazwę kampanii i grupy reklam, typ elementu (reklama / rozszerzenie), pierwotny URL, kod odpowiedzi HTTP, nowy adres URL (w przypadku 301).
Dodatkowo skrypt może wysyłać jedno zbiorcze powiadomienie mailowe dziennie, dzięki czemu nie spamuje, ale informuje o realnych problemach.
Wersja na jedno konto i na MCK (Moje Centrum Klienta)
We wpisie dostępne są dwie wersje skryptu:
- wersja na pojedyncze konto Google Ads – do wdrożenia bezpośrednio w koncie reklamowym,
- wersja na poziomie Mojego Centrum Klienta (MCK) – do kontroli wielu kont jednocześnie (dla agencji i specjalistów Google Ads).
Obie wersje działają w ten sam sposób i różnią się wyłącznie zakresem działania.
Skrypt na poziomie pojedynczego konta Google Ads
Poniżej znajdziesz gotowy skrypt do zastosowania na pojedynczym koncie Google Ads.
/**
* Single-account Script: URL checker (ADS + SITELINKS) + daily email
*
* Co robi:
* - działa na poziomie POJEDYNCZEGO konta (bez MCC, bez etykiet)
* - sprawdza TYLKO:
* 1) linki w reklamach: ENABLED campaign + ENABLED ad group + ENABLED ad
* 2) linki w sitelinkach dodanych do:
* - ENABLED campaign (campaign-level)
* - ENABLED ad group w ENABLED campaign (ad-group-level)
* - raportuje TYLKO: 301, 404, 500 (ignoruje 200 i resztę)
* - 301 = raportuje zmianę URL (Location -> final_url_after_redirect)
* - zapis do Arkusza Google
* - email: TYLKO 1x dziennie (jeśli są problemy)
*/
const CONFIG = {
// Google Sheet
SPREADSHEET_URL: 'SPREADSHEET_URL',
SHEET_NAME: 'URL Issues',
// Notify email (1x dziennie)
NOTIFY_EMAIL: 'EMAIL_ADDRESS',
// HTTP behavior
TIMEOUT_MS: 15000,
USER_AGENT: 'Mozilla/5.0 (URL Checker; Google Ads Scripts)',
// Limits / safety
MAX_UNIQUE_URLS_TOTAL: 5000, // unique URLs per run (w tym 1 koncie)
MAX_UNIQUE_URLS_PER_ACCOUNT: 1500, // unique URLs per konto (tu: to samo konto)
WRITE_BATCH_SIZE: 300,
// Extensions
CHECK_SITELINKS: true
};
// Execution control
const EXEC = {
MAX_RUN_MS: 25 * 60 * 1000, // 25 minut
STOP_BUFFER_MS: 60 * 1000 // 60s bufor na zapis/wyjście
};
// Daily email state
const STATE = {
PROP_LAST_EMAIL_DAY: 'URLCHECK_SINGLE_LAST_EMAIL_DAY' // YYYY-MM-DD -> mail poszedł dziś?
};
function main() {
const startedAt = Date.now();
const props = PropertiesService.getScriptProperties();
const runId = String(new Date().getTime());
const ss = SpreadsheetApp.openByUrl(CONFIG.SPREADSHEET_URL);
const sheet = getOrCreateSheet_(ss, CONFIG.SHEET_NAME);
// Single-account: czyścimy arkusz na starcie każdego uruchomienia
resetSheet_(sheet);
const urlCache = {}; // url -> result
let uniqueTotal = 0;
let writeBuffer = [];
// Dane konta
const account = AdsApp.currentAccount();
const accountName = account.getName();
const accountId = account.getCustomerId();
Logger.log(`Checking account: ${accountName} (${accountId})`);
const perAccountSeen = new Set();
// ENABLED campaigns only
const campaigns = AdsApp.campaigns()
.withCondition("Status = ENABLED")
.get();
while (campaigns.hasNext()) {
// STOP po 25 minutach (z buforem)
if (Date.now() - startedAt > (EXEC.MAX_RUN_MS - EXEC.STOP_BUFFER_MS)) {
Logger.log('Reached max run time ~25min - saving partial results and exiting.');
if (writeBuffer.length) appendRows_(sheet, writeBuffer);
return;
}
const campaign = campaigns.next();
const campaignName = campaign.getName();
// Campaign-level sitelinks ONLY for enabled campaign
if (CONFIG.CHECK_SITELINKS) {
const addRes = checkSitelinksForObject_(
campaign, runId, accountName, accountId, campaignName, '', 'CAMPAIGN',
perAccountSeen, urlCache, sheet, writeBuffer, uniqueTotal
);
writeBuffer = addRes.writeBuffer;
uniqueTotal = addRes.uniqueTotal;
if (uniqueTotal >= CONFIG.MAX_UNIQUE_URLS_TOTAL) break;
}
// ENABLED ad groups only
const adGroups = campaign.adGroups()
.withCondition("Status = ENABLED")
.get();
while (adGroups.hasNext()) {
// STOP po 25 minutach (z buforem)
if (Date.now() - startedAt > (EXEC.MAX_RUN_MS - EXEC.STOP_BUFFER_MS)) {
Logger.log('Reached max run time ~25min - saving partial results and exiting.');
if (writeBuffer.length) appendRows_(sheet, writeBuffer);
return;
}
const adGroup = adGroups.next();
const adGroupName = adGroup.getName();
// AdGroup-level sitelinks ONLY for enabled adgroup in enabled campaign
if (CONFIG.CHECK_SITELINKS) {
const addRes = checkSitelinksForObject_(
adGroup, runId, accountName, accountId, campaignName, adGroupName, 'AD_GROUP',
perAccountSeen, urlCache, sheet, writeBuffer, uniqueTotal
);
writeBuffer = addRes.writeBuffer;
uniqueTotal = addRes.uniqueTotal;
if (uniqueTotal >= CONFIG.MAX_UNIQUE_URLS_TOTAL) break;
}
// ENABLED ads only (in enabled adgroup + enabled campaign)
const ads = adGroup.ads()
.withCondition("Status = ENABLED")
.get();
while (ads.hasNext()) {
// STOP po 25 minutach (z buforem)
if (Date.now() - startedAt > (EXEC.MAX_RUN_MS - EXEC.STOP_BUFFER_MS)) {
Logger.log('Reached max run time ~25min - saving partial results and exiting.');
if (writeBuffer.length) appendRows_(sheet, writeBuffer);
return;
}
const ad = ads.next();
const urls = safeGetFinalUrlsFromAd_(ad);
for (let i = 0; i < urls.length; i++) {
const url = normalizeUrl_(urls[i]);
if (!url) continue;
if (!perAccountSeen.has(url)) {
if (perAccountSeen.size >= CONFIG.MAX_UNIQUE_URLS_PER_ACCOUNT) break;
perAccountSeen.add(url);
}
if (!urlCache[url]) {
if (uniqueTotal >= CONFIG.MAX_UNIQUE_URLS_TOTAL) break;
uniqueTotal++;
}
const res = getOrCheckUrl_(url, urlCache); // reports only 301/404/500
if (res.issueType) {
writeBuffer.push([
runId,
new Date(),
accountName,
accountId,
campaignName,
adGroupName,
'AD',
safeGetAdType_(ad),
safeGetAdId_(ad),
url,
res.httpCode || '',
res.issueType,
res.finalUrl || '',
res.redirectChain || '',
res.error || ''
]);
if (writeBuffer.length >= CONFIG.WRITE_BATCH_SIZE) {
appendRows_(sheet, writeBuffer);
writeBuffer = [];
}
}
}
if (uniqueTotal >= CONFIG.MAX_UNIQUE_URLS_TOTAL) break;
}
if (uniqueTotal >= CONFIG.MAX_UNIQUE_URLS_TOTAL) break;
}
if (uniqueTotal >= CONFIG.MAX_UNIQUE_URLS_TOTAL) break;
}
if (writeBuffer.length) appendRows_(sheet, writeBuffer);
// Email: tylko 1x dziennie
const today = formatDateYMD_(new Date());
const lastEmailDay = props.getProperty(STATE.PROP_LAST_EMAIL_DAY);
if (lastEmailDay !== today) {
const summary = countIssuesForRun_(sheet, runId);
if (summary.total > 0) {
sendSummaryEmail_(runId, summary);
props.setProperty(STATE.PROP_LAST_EMAIL_DAY, today);
} else {
Logger.log('No issues found. No email sent.');
}
} else {
Logger.log('Daily email already sent today. Skipping email.');
}
}
/* -------------------- URL checking: ONLY 301 / 404 / 500 -------------------- */
function getOrCheckUrl_(url, cache) {
if (cache[url]) return cache[url];
const res = checkUrlSelective_(url);
cache[url] = res;
return res;
}
function checkUrlSelective_(startUrl) {
const resp = fetchNoFollow_(startUrl);
if (resp.error) {
// skupiamy się na 301/404/500 -> fetch error pomijamy
return { issueType: null, httpCode: null, finalUrl: '', redirectChain: '', error: '' };
}
const code = resp.code;
if (code === 301) {
const loc = resp.location || '';
const nextUrl = loc ? resolveRedirectUrl_(startUrl, loc) : '';
return {
issueType: 'REDIRECT_301',
httpCode: 301,
finalUrl: nextUrl || '',
redirectChain: loc ? `${startUrl} (301) -> ${nextUrl || loc}` : `${startUrl} (301)`,
error: loc ? '' : '301 without Location header'
};
}
if (code === 404) {
return { issueType: 'HTTP_404', httpCode: 404, finalUrl: '', redirectChain: '', error: 'Not Found' };
}
if (code === 500) {
return { issueType: 'HTTP_500', httpCode: 500, finalUrl: '', redirectChain: '', error: 'Server Error' };
}
return { issueType: null, httpCode: code, finalUrl: '', redirectChain: '', error: '' };
}
function fetchNoFollow_(url) {
try {
const resp = UrlFetchApp.fetch(url, {
muteHttpExceptions: true,
followRedirects: false,
validateHttpsCertificates: true,
timeout: CONFIG.TIMEOUT_MS,
headers: { 'User-Agent': CONFIG.USER_AGENT }
});
const code = resp.getResponseCode();
const headers = safeGetHeaders_(resp);
const location = headers['Location'] || headers['location'] || '';
return { code: code, location: location, error: '' };
} catch (e) {
return { code: null, location: '', error: String(e) };
}
}
function safeGetHeaders_(resp) {
try {
return resp.getAllHeaders ? resp.getAllHeaders() : resp.getHeaders();
} catch (e) {
return {};
}
}
function resolveRedirectUrl_(baseUrl, location) {
const loc = String(location).trim();
if (!loc) return '';
if (/^https?:\/\//i.test(loc)) return loc;
if (/^\/\//.test(loc)) {
const proto = baseUrl.startsWith('https://') ? 'https:' : 'http:';
return proto + loc;
}
try {
const m = baseUrl.match(/^(https?:\/\/[^\/?#]+)(\/[^?#]*)?/i);
if (!m) return '';
const origin = m[1];
const path = m[2] || '/';
if (loc.startsWith('/')) return origin + loc;
const dir = path.replace(/[^\/]*$/, '');
return origin + dir + loc;
} catch (e) {
return '';
}
}
function normalizeUrl_(u) {
if (!u) return '';
return String(u).trim();
}
/* -------------------- Sitelinks (ONLY attached to enabled campaign/adgroup we iterate) -------------------- */
function checkSitelinksForObject_(
objWithExtensions,
runId,
accountName,
accountId,
campaignName,
adGroupName,
level,
perAccountSeen,
urlCache,
sheet,
writeBuffer,
uniqueTotal
) {
try {
const extSource = getExtensionsContainer_(objWithExtensions);
if (!extSource) return { writeBuffer, uniqueTotal };
// IMPORTANT: no withCondition("Status = ENABLED") in selector (not supported); filter in code
const sitelinksIt = extSource.sitelinks().get();
while (sitelinksIt.hasNext()) {
const sl = sitelinksIt.next();
if (typeof sl.isEnabled === 'function' && !sl.isEnabled()) continue;
const url = normalizeUrl_(safeGetFinalUrlFromExtension_(sl));
if (!url) continue;
if (!perAccountSeen.has(url)) {
if (perAccountSeen.size >= CONFIG.MAX_UNIQUE_URLS_PER_ACCOUNT) break;
perAccountSeen.add(url);
}
if (!urlCache[url]) {
if (uniqueTotal >= CONFIG.MAX_UNIQUE_URLS_TOTAL) break;
uniqueTotal++;
}
const res = getOrCheckUrl_(url, urlCache);
if (res.issueType) {
writeBuffer.push([
runId,
new Date(),
accountName,
accountId,
campaignName,
adGroupName,
`EXT_${level}`,
'SITELINK',
safeGetExtensionId_(sl),
url,
res.httpCode || '',
res.issueType,
res.finalUrl || '',
res.redirectChain || '',
res.error || ''
]);
if (writeBuffer.length >= CONFIG.WRITE_BATCH_SIZE) {
appendRows_(sheet, writeBuffer);
writeBuffer = [];
}
}
}
} catch (e) {
Logger.log(`Sitelinks check failed (${level}). Error: ${e}`);
}
return { writeBuffer, uniqueTotal };
}
function getExtensionsContainer_(obj) {
try {
if (obj && typeof obj.extensions === 'function') return obj.extensions();
} catch (e) {}
return null;
}
function safeGetFinalUrlFromExtension_(ext) {
try {
if (ext && typeof ext.urls === 'function') {
const u = ext.urls();
if (u) {
if (typeof u.getFinalUrl === 'function') return u.getFinalUrl();
if (typeof u.getFinalUrls === 'function') {
const arr = u.getFinalUrls();
return (arr && arr.length) ? arr[0] : '';
}
}
}
} catch (e) {}
return '';
}
function safeGetExtensionId_(ext) {
try {
if (ext && typeof ext.getId === 'function') return ext.getId();
} catch (e) {}
return '';
}
/* -------------------- Sheet -------------------- */
function resetSheet_(sheet) {
sheet.clearContents();
const headers = [
'run_id',
'checked_at',
'account_name',
'account_id',
'campaign_name',
'ad_group_name',
'entity_kind',
'entity_type',
'entity_id',
'url',
'http_code',
'issue_type',
'final_url_after_redirect',
'redirect_chain',
'error'
];
sheet.getRange(1, 1, 1, headers.length).setValues([headers]);
}
function appendRows_(sheet, rows) {
if (!rows || !rows.length) return;
const startRow = sheet.getLastRow() + 1;
sheet.getRange(startRow, 1, rows.length, rows[0].length).setValues(rows);
}
function getOrCreateSheet_(ss, name) {
let sheet = ss.getSheetByName(name);
if (!sheet) sheet = ss.insertSheet(name);
return sheet;
}
/* -------------------- Email summary (only once per day) -------------------- */
function countIssuesForRun_(sheet, runId) {
const lastRow = sheet.getLastRow();
if (lastRow <= 1) return { total: 0, c301: 0, c404: 0, c500: 0, sample: [] };
const values = sheet.getRange(2, 1, lastRow - 1, sheet.getLastColumn()).getValues();
let c301 = 0, c404 = 0, c500 = 0, total = 0;
const sample = [];
for (let i = 0; i < values.length; i++) {
const r = values[i];
const rid = String(r[0] || '');
if (rid !== String(runId)) continue;
const issueType = r[11];
if (!issueType) continue;
total++;
if (issueType === 'REDIRECT_301') c301++;
if (issueType === 'HTTP_404') c404++;
if (issueType === 'HTTP_500') c500++;
if (sample.length < 50) sample.push(r);
}
return { total, c301, c404, c500, sample };
}
function sendSummaryEmail_(runId, summary) {
const subject = `Google Ads URL Alert: 301=${summary.c301}, 404=${summary.c404}, 500=${summary.c500} (Total=${summary.total})`;
const lines = [];
lines.push(`Wykryto problemy z URL-ami w Google Ads (single account). Raportujemy tylko 301, 404, 500.`);
lines.push(`Run ID: ${runId}`);
lines.push(`301 (redirect): ${summary.c301}`);
lines.push(`404: ${summary.c404}`);
lines.push(`500: ${summary.c500}`);
lines.push(`Łącznie: ${summary.total}`);
lines.push('');
lines.push('Przykłady (max 50):');
for (let i = 0; i < summary.sample.length; i++) {
const r = summary.sample[i];
const acc = `${r[2]} (${r[3]})`;
const camp = r[4] || '-';
const ag = r[5] || '-';
const kind = r[6];
const type = r[7];
const url = r[9];
const code = r[10];
const issue = r[11];
const finalUrl = r[12] || '';
lines.push(`- ${issue} | ${code} | ${acc} | ${camp} | ${ag} | ${kind}/${type} | ${url} -> ${finalUrl}`.trim());
}
lines.push('');
lines.push('Szczegóły w Arkuszu:');
lines.push(CONFIG.SPREADSHEET_URL);
MailApp.sendEmail({
to: CONFIG.NOTIFY_EMAIL,
subject: subject,
body: lines.join('\n')
});
}
/* -------------------- Helpers -------------------- */
function safeGetFinalUrlsFromAd_(ad) {
try {
if (ad && typeof ad.urls === 'function') {
const u = ad.urls();
if (u && typeof u.getFinalUrls === 'function') {
const arr = u.getFinalUrls();
return arr ? arr : [];
}
if (u && typeof u.getFinalUrl === 'function') {
const one = u.getFinalUrl();
return one ? [one] : [];
}
}
} catch (e) {}
return [];
}
function safeGetAdType_(ad) {
try {
if (ad && typeof ad.getType === 'function') return ad.getType();
} catch (e) {}
return '';
}
function safeGetAdId_(ad) {
try {
if (ad && typeof ad.getId === 'function') return ad.getId();
} catch (e) {}
return '';
}
function formatDateYMD_(d) {
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
return `${yyyy}-${mm}-${dd}`;
}Zaloguj się na swoje konto Google Ads i dodaj nowy skrypt.

Następnie dodaj skopiowany skrypt.

W miejscu SPREADSHEET_URL: 'SPREADSHEET_URL’, podaj link do udostępnionego Arkusza Google, w którym będą prezentowane linki z błędami 301, 404 i 500.
W miejscu NOTIFY_EMAIL: 'EMAIL_ADDRESS’, dodaj swój adres mailowy, na który będą przychodzić powiadomienia. Następnie AUTORYZUJ skrypt i kliknij PODGLĄD.
Jeśli wszystko działa poprawnie, w Arkuszu Google powinny pojawić się dane, np.

Jednocześnie powinna przyjść wiadomość mailowa z wyszczególnieniem linków:

Jeśli wszystko działa jak należy, zapisz skrypt i ustaw jego harmonogram na godzinny.

Skrypt na poziomie Mojego Centrum Klienta (MCK)
Poniżej znajdziesz skrypt, który możesz wykorzystać na poziomie Mojego Centrum Klienta, aby sprawdzać wiele konto jednocześnie. Skrypt działa w oparciu o etykietę, więc musisz ją przypisać na poziomie kont, które chcesz sprawdzić, a następnie dodać ją w ustawieniach skryptu.
/**
* MCC Script: URL checker (ADS + SITELINKS) with resume/checkpoint + daily email
*
* Spełnia wymagania:
* - MCC
* - wybiera TYLKO konta z etykietą na poziomie konta MCC (ACCOUNT_LABEL)
* - sprawdza TYLKO:
* 1) linki w reklamach: ENABLED campaign + ENABLED ad group + ENABLED ad
* 2) linki w sitelinkach dodanych do:
* - ENABLED campaign (campaign-level)
* - ENABLED ad group w ENABLED campaign (ad-group-level)
* - raportuje TYLKO: 301, 404, 500 (ignoruje 200 i resztę)
* - 301 = raportuje zmianę URL (Location -> final_url_after_redirect)
* - zapis do Arkusza Google
* - mechanizm checkpoint/resume (bez tworzenia dodatkowych etykiet)
* - limit czasu pracy: max 25 minut na uruchomienie (potem stop i resume w kolejnym runie)
* - email: TYLKO 1x dziennie (nawet jeśli skrypt kończy pełny przebieg kilka razy w ciągu dnia)
* - email jest wysyłany dopiero po zakończeniu pełnego przebiegu po wszystkich kontach (a nie w trakcie)
*/
const CONFIG = {
// Google Sheet
SPREADSHEET_URL: 'SPREADSHEET_URL',
SHEET_NAME: 'URL Issues',
// MCC account label (applied to client accounts in MCC)
ACCOUNT_LABEL: 'LABEL_NAME',
// Notify email (1x dziennie)
NOTIFY_EMAIL: 'EMAIL_ADDRESS',
// HTTP behavior
TIMEOUT_MS: 15000,
USER_AGENT: 'Mozilla/5.0 (URL Checker; Google Ads Scripts)',
// Limits / safety
MAX_UNIQUE_URLS_TOTAL: 5000, // unique URLs per full run (across resumes)
MAX_UNIQUE_URLS_PER_ACCOUNT: 1500, // unique URLs per account (per run execution)
WRITE_BATCH_SIZE: 300,
// Extensions
CHECK_SITELINKS: true
};
// Execution control
const EXEC = {
MAX_RUN_MS: 25 * 60 * 1000, // 25 minut
STOP_BUFFER_MS: 60 * 1000 // 60s bufor na zapis/wyjście
};
// Checkpoint/resume + daily email state
const STATE = {
PROP_LAST_CID: 'URLCHECK_LAST_CID', // ostatnie przetworzone konto (CID)
PROP_RUN_ID: 'URLCHECK_RUN_ID', // stały runId dla całego przebiegu (również przy resume)
PROP_LAST_EMAIL_DAY: 'URLCHECK_LAST_EMAIL_DAY' // YYYY-MM-DD -> mail poszedł dziś?
};
function main() {
const startedAt = Date.now();
const props = PropertiesService.getScriptProperties();
const lastCid = props.getProperty(STATE.PROP_LAST_CID); // jeśli istnieje -> wznawiamy
const isResuming = !!lastCid;
// runId: stały dla całego przebiegu (również przy wznowieniach)
let runId = props.getProperty(STATE.PROP_RUN_ID);
if (!runId) runId = String(new Date().getTime());
props.setProperty(STATE.PROP_RUN_ID, runId);
const ss = SpreadsheetApp.openByUrl(CONFIG.SPREADSHEET_URL);
const sheet = getOrCreateSheet_(ss, CONFIG.SHEET_NAME);
// Czyścimy arkusz tylko na starcie nowego pełnego przebiegu
if (!isResuming) {
resetSheet_(sheet);
// nowy przebieg => reset limitów globalnych per przebieg (urlCache i uniqueTotal i tak są per uruchomienie,
// ale globalny limit MAX_UNIQUE_URLS_TOTAL dotyczy jednego uruchomienia; tu zostawiamy jak jest)
}
// Cache per uruchomienie (żeby nie fetchować tego samego URL wiele razy w jednym runie)
const urlCache = {}; // url -> result
let uniqueTotal = 0;
let writeBuffer = [];
// Deterministyczna kolejność kont
const accountsIt = AdsManagerApp.accounts()
.withCondition(`LabelNames CONTAINS_ANY ['${escapeQuotes_(CONFIG.ACCOUNT_LABEL)}']`)
.orderBy('CustomerId ASC')
.get();
if (!accountsIt.hasNext()) {
Logger.log(`No accounts found with label: ${CONFIG.ACCOUNT_LABEL}`);
// reset stanu
props.deleteProperty(STATE.PROP_LAST_CID);
props.deleteProperty(STATE.PROP_RUN_ID);
return;
}
// Resume: pomijamy konta aż do lastCid, potem zaczynamy od następnego
let started = !isResuming;
while (accountsIt.hasNext()) {
// STOP po 25 minutach (z buforem)
if (Date.now() - startedAt > (EXEC.MAX_RUN_MS - EXEC.STOP_BUFFER_MS)) {
Logger.log('Reached max run time ~25min - saving state and exiting to resume later.');
if (writeBuffer.length) appendRows_(sheet, writeBuffer);
// NIE wysyłamy maila - nie skończyliśmy pełnego przebiegu po wszystkich kontach
return;
}
const account = accountsIt.next();
const cid = account.getCustomerId();
if (!started) {
// pomijamy aż trafimy na ostatnie przetworzone konto
if (cid === lastCid) {
started = true; // następne konto będzie pierwszym do sprawdzenia
}
continue;
}
AdsManagerApp.select(account);
const res = processAccount_(account, runId, urlCache, uniqueTotal, sheet, writeBuffer);
uniqueTotal = res.uniqueTotal;
writeBuffer = res.writeBuffer;
// zapis checkpointu = właśnie przetworzone konto (wznowimy od kolejnego)
props.setProperty(STATE.PROP_LAST_CID, cid);
// globalny limit unikalnych URL w tym uruchomieniu (bezpiecznik)
if (uniqueTotal >= CONFIG.MAX_UNIQUE_URLS_TOTAL) {
Logger.log(`Reached MAX_UNIQUE_URLS_TOTAL=${CONFIG.MAX_UNIQUE_URLS_TOTAL}. Saving state and exiting to resume later.`);
if (writeBuffer.length) appendRows_(sheet, writeBuffer);
return;
}
}
// Koniec wszystkich kont (pełny przebieg ukończony)
if (writeBuffer.length) appendRows_(sheet, writeBuffer);
// Email: TYLKO raz dziennie, i tylko po pełnym przebiegu
const today = formatDateYMD_(new Date());
const lastEmailDay = props.getProperty(STATE.PROP_LAST_EMAIL_DAY);
if (lastEmailDay !== today) {
const summary = countIssuesForRun_(sheet, runId);
if (summary.total > 0) {
sendSummaryEmail_(runId, summary);
props.setProperty(STATE.PROP_LAST_EMAIL_DAY, today);
} else {
Logger.log('No issues found in full run. No email sent.');
}
} else {
Logger.log('Daily email already sent today. Skipping email.');
}
// Reset stanu (przebieg zakończony)
props.deleteProperty(STATE.PROP_LAST_CID);
props.deleteProperty(STATE.PROP_RUN_ID);
}
/* -------------------- Per-account processing -------------------- */
function processAccount_(account, runId, urlCache, uniqueTotal, sheet, writeBuffer) {
const accountName = account.getName();
const accountId = account.getCustomerId();
Logger.log(`Checking account: ${accountName} (${accountId})`);
const perAccountSeen = new Set();
// ENABLED campaigns only
const campaigns = AdsApp.campaigns()
.withCondition("Status = ENABLED")
.get();
while (campaigns.hasNext()) {
const campaign = campaigns.next();
const campaignName = campaign.getName();
// Campaign-level sitelinks ONLY for enabled campaign
if (CONFIG.CHECK_SITELINKS) {
const addRes = checkSitelinksForObject_(
campaign, runId, accountName, accountId, campaignName, '', 'CAMPAIGN',
perAccountSeen, urlCache, sheet, writeBuffer, uniqueTotal
);
writeBuffer = addRes.writeBuffer;
uniqueTotal = addRes.uniqueTotal;
if (uniqueTotal >= CONFIG.MAX_UNIQUE_URLS_TOTAL) break;
}
// ENABLED ad groups only
const adGroups = campaign.adGroups()
.withCondition("Status = ENABLED")
.get();
while (adGroups.hasNext()) {
const adGroup = adGroups.next();
const adGroupName = adGroup.getName();
// AdGroup-level sitelinks ONLY for enabled adgroup in enabled campaign
if (CONFIG.CHECK_SITELINKS) {
const addRes = checkSitelinksForObject_(
adGroup, runId, accountName, accountId, campaignName, adGroupName, 'AD_GROUP',
perAccountSeen, urlCache, sheet, writeBuffer, uniqueTotal
);
writeBuffer = addRes.writeBuffer;
uniqueTotal = addRes.uniqueTotal;
if (uniqueTotal >= CONFIG.MAX_UNIQUE_URLS_TOTAL) break;
}
// ENABLED ads only (in enabled adgroup + enabled campaign)
const ads = adGroup.ads()
.withCondition("Status = ENABLED")
.get();
while (ads.hasNext()) {
const ad = ads.next();
const urls = safeGetFinalUrlsFromAd_(ad);
for (let i = 0; i < urls.length; i++) {
const url = normalizeUrl_(urls[i]);
if (!url) continue;
// per-account unique limit
if (!perAccountSeen.has(url)) {
if (perAccountSeen.size >= CONFIG.MAX_UNIQUE_URLS_PER_ACCOUNT) break;
perAccountSeen.add(url);
}
// global unique limit per run execution
if (!urlCache[url]) {
if (uniqueTotal >= CONFIG.MAX_UNIQUE_URLS_TOTAL) break;
uniqueTotal++;
}
const res = getOrCheckUrl_(url, urlCache); // reports only 301/404/500
if (res.issueType) {
writeBuffer.push([
runId,
new Date(),
accountName,
accountId,
campaignName,
adGroupName,
'AD',
safeGetAdType_(ad),
safeGetAdId_(ad),
url,
res.httpCode || '',
res.issueType,
res.finalUrl || '',
res.redirectChain || '',
res.error || ''
]);
if (writeBuffer.length >= CONFIG.WRITE_BATCH_SIZE) {
appendRows_(sheet, writeBuffer);
writeBuffer = [];
}
}
}
if (uniqueTotal >= CONFIG.MAX_UNIQUE_URLS_TOTAL) break;
}
if (uniqueTotal >= CONFIG.MAX_UNIQUE_URLS_TOTAL) break;
}
if (uniqueTotal >= CONFIG.MAX_UNIQUE_URLS_TOTAL) break;
}
return { writeBuffer: writeBuffer, uniqueTotal: uniqueTotal };
}
/* -------------------- URL checking: ONLY 301 / 404 / 500 -------------------- */
function getOrCheckUrl_(url, cache) {
if (cache[url]) return cache[url];
const res = checkUrlSelective_(url);
cache[url] = res;
return res;
}
function checkUrlSelective_(startUrl) {
const resp = fetchNoFollow_(startUrl);
if (resp.error) {
// fokus tylko 301/404/500 -> fetch error pomijamy
return { issueType: null, httpCode: null, finalUrl: '', redirectChain: '', error: '' };
}
const code = resp.code;
if (code === 301) {
const loc = resp.location || '';
const nextUrl = loc ? resolveRedirectUrl_(startUrl, loc) : '';
return {
issueType: 'REDIRECT_301',
httpCode: 301,
finalUrl: nextUrl || '',
redirectChain: loc ? `${startUrl} (301) -> ${nextUrl || loc}` : `${startUrl} (301)`,
error: loc ? '' : '301 without Location header'
};
}
if (code === 404) {
return { issueType: 'HTTP_404', httpCode: 404, finalUrl: '', redirectChain: '', error: 'Not Found' };
}
if (code === 500) {
return { issueType: 'HTTP_500', httpCode: 500, finalUrl: '', redirectChain: '', error: 'Server Error' };
}
// ignoruj wszystko inne
return { issueType: null, httpCode: code, finalUrl: '', redirectChain: '', error: '' };
}
function fetchNoFollow_(url) {
try {
const resp = UrlFetchApp.fetch(url, {
muteHttpExceptions: true,
followRedirects: false,
validateHttpsCertificates: true,
timeout: CONFIG.TIMEOUT_MS,
headers: { 'User-Agent': CONFIG.USER_AGENT }
});
const code = resp.getResponseCode();
const headers = safeGetHeaders_(resp);
const location = headers['Location'] || headers['location'] || '';
return { code: code, location: location, error: '' };
} catch (e) {
return { code: null, location: '', error: String(e) };
}
}
function safeGetHeaders_(resp) {
try {
return resp.getAllHeaders ? resp.getAllHeaders() : resp.getHeaders();
} catch (e) {
return {};
}
}
function resolveRedirectUrl_(baseUrl, location) {
const loc = String(location).trim();
if (!loc) return '';
if (/^https?:\/\//i.test(loc)) return loc;
if (/^\/\//.test(loc)) {
const proto = baseUrl.startsWith('https://') ? 'https:' : 'http:';
return proto + loc;
}
try {
const m = baseUrl.match(/^(https?:\/\/[^\/?#]+)(\/[^?#]*)?/i);
if (!m) return '';
const origin = m[1];
const path = m[2] || '/';
if (loc.startsWith('/')) return origin + loc;
const dir = path.replace(/[^\/]*$/, '');
return origin + dir + loc;
} catch (e) {
return '';
}
}
function normalizeUrl_(u) {
if (!u) return '';
return String(u).trim();
}
/* -------------------- Sitelinks (ONLY attached to enabled campaign/adgroup we iterate) -------------------- */
function checkSitelinksForObject_(
objWithExtensions,
runId,
accountName,
accountId,
campaignName,
adGroupName,
level,
perAccountSeen,
urlCache,
sheet,
writeBuffer,
uniqueTotal
) {
try {
const extSource = getExtensionsContainer_(objWithExtensions);
if (!extSource) return { writeBuffer, uniqueTotal };
// IMPORTANT: no withCondition("Status = ENABLED") in selector (not supported); filter in code
const sitelinksIt = extSource.sitelinks().get();
while (sitelinksIt.hasNext()) {
const sl = sitelinksIt.next();
if (typeof sl.isEnabled === 'function' && !sl.isEnabled()) continue;
const url = normalizeUrl_(safeGetFinalUrlFromExtension_(sl));
if (!url) continue;
if (!perAccountSeen.has(url)) {
if (perAccountSeen.size >= CONFIG.MAX_UNIQUE_URLS_PER_ACCOUNT) break;
perAccountSeen.add(url);
}
if (!urlCache[url]) {
if (uniqueTotal >= CONFIG.MAX_UNIQUE_URLS_TOTAL) break;
uniqueTotal++;
}
const res = getOrCheckUrl_(url, urlCache);
if (res.issueType) {
writeBuffer.push([
runId,
new Date(),
accountName,
accountId,
campaignName,
adGroupName,
`EXT_${level}`,
'SITELINK',
safeGetExtensionId_(sl),
url,
res.httpCode || '',
res.issueType,
res.finalUrl || '',
res.redirectChain || '',
res.error || ''
]);
if (writeBuffer.length >= CONFIG.WRITE_BATCH_SIZE) {
appendRows_(sheet, writeBuffer);
writeBuffer = [];
}
}
}
} catch (e) {
Logger.log(`Sitelinks check failed (${level}). Error: ${e}`);
}
return { writeBuffer, uniqueTotal };
}
function getExtensionsContainer_(obj) {
try {
if (obj && typeof obj.extensions === 'function') return obj.extensions();
} catch (e) {}
return null;
}
function safeGetFinalUrlFromExtension_(ext) {
try {
if (ext && typeof ext.urls === 'function') {
const u = ext.urls();
if (u) {
if (typeof u.getFinalUrl === 'function') return u.getFinalUrl();
if (typeof u.getFinalUrls === 'function') {
const arr = u.getFinalUrls();
return (arr && arr.length) ? arr[0] : '';
}
}
}
} catch (e) {}
return '';
}
function safeGetExtensionId_(ext) {
try {
if (ext && typeof ext.getId === 'function') return ext.getId();
} catch (e) {}
return '';
}
/* -------------------- Sheet -------------------- */
function resetSheet_(sheet) {
sheet.clearContents();
const headers = [
'run_id',
'checked_at',
'account_name',
'account_id',
'campaign_name',
'ad_group_name',
'entity_kind',
'entity_type',
'entity_id',
'url',
'http_code',
'issue_type',
'final_url_after_redirect',
'redirect_chain',
'error'
];
sheet.getRange(1, 1, 1, headers.length).setValues([headers]);
}
function appendRows_(sheet, rows) {
if (!rows || !rows.length) return;
const startRow = sheet.getLastRow() + 1;
sheet.getRange(startRow, 1, rows.length, rows[0].length).setValues(rows);
}
function getOrCreateSheet_(ss, name) {
let sheet = ss.getSheetByName(name);
if (!sheet) sheet = ss.insertSheet(name);
return sheet;
}
/* -------------------- Email summary (only after full run; only once per day) -------------------- */
function countIssuesForRun_(sheet, runId) {
const lastRow = sheet.getLastRow();
if (lastRow <= 1) return { total: 0, c301: 0, c404: 0, c500: 0, sample: [] };
const values = sheet.getRange(2, 1, lastRow - 1, sheet.getLastColumn()).getValues();
let c301 = 0, c404 = 0, c500 = 0, total = 0;
const sample = [];
for (let i = 0; i < values.length; i++) {
const r = values[i];
const rid = String(r[0] || '');
if (rid !== String(runId)) continue;
const issueType = r[11];
if (!issueType) continue;
total++;
if (issueType === 'REDIRECT_301') c301++;
if (issueType === 'HTTP_404') c404++;
if (issueType === 'HTTP_500') c500++;
if (sample.length < 50) sample.push(r);
}
return { total, c301, c404, c500, sample };
}
function sendSummaryEmail_(runId, summary) {
const subject = `Google Ads URL Alert: 301=${summary.c301}, 404=${summary.c404}, 500=${summary.c500} (Total=${summary.total})`;
const lines = [];
lines.push(`Wykryto problemy z URL-ami w Google Ads (MCC). Raportujemy tylko 301, 404, 500.`);
lines.push(`Run ID: ${runId}`);
lines.push(`301 (redirect): ${summary.c301}`);
lines.push(`404: ${summary.c404}`);
lines.push(`500: ${summary.c500}`);
lines.push(`Łącznie: ${summary.total}`);
lines.push('');
lines.push('Przykłady (max 50):');
for (let i = 0; i < summary.sample.length; i++) {
const r = summary.sample[i];
const acc = `${r[2]} (${r[3]})`;
const camp = r[4] || '-';
const ag = r[5] || '-';
const kind = r[6];
const type = r[7];
const url = r[9];
const code = r[10];
const issue = r[11];
const finalUrl = r[12] || '';
lines.push(`- ${issue} | ${code} | ${acc} | ${camp} | ${ag} | ${kind}/${type} | ${url} -> ${finalUrl}`.trim());
}
lines.push('');
lines.push('Szczegóły w Arkuszu:');
lines.push(CONFIG.SPREADSHEET_URL);
MailApp.sendEmail({
to: CONFIG.NOTIFY_EMAIL,
subject: subject,
body: lines.join('\n')
});
}
/* -------------------- Helpers -------------------- */
function safeGetFinalUrlsFromAd_(ad) {
try {
if (ad && typeof ad.urls === 'function') {
const u = ad.urls();
if (u && typeof u.getFinalUrls === 'function') {
const arr = u.getFinalUrls();
return arr ? arr : [];
}
if (u && typeof u.getFinalUrl === 'function') {
const one = u.getFinalUrl();
return one ? [one] : [];
}
}
} catch (e) {}
return [];
}
function safeGetAdType_(ad) {
try {
if (ad && typeof ad.getType === 'function') return ad.getType();
} catch (e) {}
return '';
}
function safeGetAdId_(ad) {
try {
if (ad && typeof ad.getId === 'function') return ad.getId();
} catch (e) {}
return '';
}
function escapeQuotes_(s) {
return String(s).replace(/'/g, "\\'");
}
function formatDateYMD_(d) {
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
return `${yyyy}-${mm}-${dd}`;
}W miejscu SPREADSHEET_URL: 'SPREADSHEET_URL’, podaj link do udostępnionego Arkusza Google, w którym będą prezentowane linki z błędami 301, 404 i 500.
W miejscu NOTIFY_EMAIL: 'EMAIL_ADDRESS’, dodaj swój adres mailowy, na który będą przychodzić powiadomienia.
W miejscu ACCOUNT_LABEL: 'LABEL_NAME’, dodaj nazwę etykiety, która przypisana jest do konkretnego konta Google Ads.
Skrypt został zaprojektowany tak, aby działać stabilnie na dużych strukturach kont Google Ads, bez konieczności ręcznego dzielenia kont, tworzenia dodatkowych etykiet czy pilnowania limitów czasu wykonywania. W tym celu zastosowałem kilka mechanizmów zabezpieczających ciągłość działania.
Podstawą jest mechanizm checkpoint / resume, który zapisuje stan przetwarzania kont w trakcie działania skryptu. Jeżeli w trakcie jednego uruchomienia zabraknie czasu, skrypt zapamiętuje ostatnio przetworzone konto i kończy działanie w kontrolowany sposób. Przy kolejnym uruchomieniu wznawia pracę dokładnie od miejsca, w którym został przerwany. Cały proces odbywa się automatycznie i nie wymaga tworzenia dodatkowych etykiet ani modyfikowania struktury kont w MCK.
Każde uruchomienie skryptu ma również wbudowany limit czasu pracy, ustawiony na maksymalnie 25 minut. Dzięki temu skrypt nie ryzykuje przerwania działania przez system Google Ads Scripts. Po osiągnięciu limitu czasu zapisuje aktualny stan i kończy działanie, aby przy następnym uruchomieniu kontynuować sprawdzanie pozostałych kont.
System powiadomień mailowych został ograniczony do jednej wiadomości dziennie, niezależnie od liczby uruchomień skryptu w ciągu dnia. Nawet jeśli pełny przebieg po wszystkich kontach zostanie ukończony kilka razy w ciągu doby, e-mail z podsumowaniem zostanie wysłany tylko raz, co zapobiega spamowaniu skrzynki i ułatwia analizę wyników.
Dodatkowo e-mail z raportem jest wysyłany wyłącznie po zakończeniu pełnego przebiegu po wszystkich kontach. Skrypt nie wysyła żadnych powiadomień w trakcie działania ani przy częściowych wynikach. Dzięki temu raport mailowy zawsze zawiera kompletny obraz sytuacji, a nie fragmentaryczne dane z jednego lub kilku kont.
Takie podejście pozwala traktować skrypt jako stałe narzędzie monitorujące, które można uruchamiać cyklicznie (np. co godzinę), bez ryzyka dublowania alertów i bez konieczności ręcznej kontroli jego pracy.
Podsumowanie
Automatyczna kontrola linków w Google Ads to element, który realnie ogranicza straty budżetu, skraca czas reakcji na zmiany po stronie klienta, daje większą kontrolę nad kampaniami, szczególnie dobrze sprawdza się w e-commerce i kampaniach long tail. Skrypt nie zastępuje optymalizacji kampanii, ale eliminuje techniczne problemy, które bardzo często pozostają niewidoczne przez długi czas. Jeśli zależy Ci na stabilności wyników i szybkim wychwytywaniu problemów z landing page – to rozwiązanie zdecydowanie warto wdrożyć.
| WSPÓŁPRACA ZE MNĄ |
| Od 2011 roku jako specjalista Google Ads zajmuję się prowadzeniem i optymalizacją kampanii w systemie reklamowym Google. Dotychczas przeprowadziłem ponad 2300 kampanii, których budżet przekroczył już 30 mln zł. Jeśli szukasz kogoś komu chcesz zlecić prowadzenie swoich kampanii, napisz do mnie. Pracuję tylko z firmami, które poważnie podchodzą do tematu, dlatego zapoznaj się proszę z moimi zasadami współpracy. Jeśli je akceptujesz, wyślij mi wiadomość :) Jeśli interesuje Cię audyt kampanii Google Ads lub konsultacje, również mogę pomóc w tej kwestii. |


4 comments
luk
27 stycznia 2026 at 13:41
Na poziomie MCK zwróciło mi nieaktywne kampanie, ale tylko z jednego konta, dziwne
Marcin Wsół
27 stycznia 2026 at 13:59
Jeśli tak się stało, to prawdopodobnie kampanie nadal są aktywne (ENABLED) ale mają ustawioną datę zakończenia. Jak je wstrzymasz, to będzie ok :)
luk
27 stycznia 2026 at 14:07
Tak, rzeczywiście, spróbuje przerobić skrypt aby nie brał on pod uwagę tego typu kampanii :)
Marcin Wsół
27 stycznia 2026 at 14:12
jasne :)