Skip to content
GetCited Blog
Chapter 4 · Deepen your Knowledge

Google Apps Script for SEO: 6 Scripts You Can Paste Into Sheets Today

Status codes, on-page tags, Search Console data, index status and Core Web Vitals, pulled straight into a Google Sheet and refreshed on a schedule. No servers, no Python environment.

Updated October 202614 min read
In one line

Apps Script is JavaScript that runs inside Google Sheets. For SEO it is the fastest way to fetch URLs, call the Search Console, URL Inspection and PageSpeed APIs, write the results into a sheet, and re-run the whole thing every week on a timer.

Most SEO automation advice starts with "install Python". For a large share of day-to-day work you do not need to. If the job is "take a list of URLs, check something about each one, put the answer in a column", Apps Script does it from inside the spreadsheet you already have open, and Google runs it for free.

This guide gives you six working scripts, in the order most SEO teams need them. Each one is copy-paste ready and explains the one or two lines you will want to change. After that come the quotas that will eventually stop you, and how to tell when it is time to move to Python.

A helper robot inside a spreadsheet grid, ferrying data between a document, an email envelope and a calendar

What Apps Script is good for (and where it stops)

Good fit: lists of a few hundred to a few thousand URLs, anything that ends up in a Sheet or a Looker Studio report, API pulls from Google's own SEO tools, and jobs that should run every day or week without anyone remembering to run them.

Poor fit: crawling a whole site, rendering JavaScript (Apps Script fetches raw HTML and does not execute scripts), anything that needs more than six minutes of continuous runtime, and datasets in the hundreds of thousands of rows. Use a crawler, Python or BigQuery for those.

Setup in two minutes

Open any Google Sheet, then Extensions → Apps Script. Delete the empty myFunction, paste a script, and press Save. The first time a script touches the internet or your Google data, Google asks you to authorise it. Because it is your own unverified script, you will see a warning. Click Advanced → Go to (project name) to continue.

For the API scripts (3, 4 and 6), also tell Apps Script which permissions you need. In the editor, open Project Settings, tick Show "appsscript.json" manifest file, and replace its contents with:

{
  "timeZone": "Etc/UTC",
  "runtimeVersion": "V8",
  "oauthScopes": [
    "https://www.googleapis.com/auth/spreadsheets.currentonly",
    "https://www.googleapis.com/auth/script.external_request",
    "https://www.googleapis.com/auth/webmasters.readonly",
    "https://www.googleapis.com/auth/script.scriptapp",
    "https://www.googleapis.com/auth/script.send_mail"
  ]
}

If an API call later fails with a 403 that says the API is not enabled, link the script to a standard Google Cloud project (Project Settings → Google Cloud Platform project) and enable the Search Console API there.

1. Bulk HTTP status and redirect checker

The single most useful SEO script. Put URLs in column A from row 2, run checkStatuses, and get the status code and redirect target in columns B and C. Use it after a migration, on a sitemap export, or on every URL in a "Page with redirect" report.

function checkStatuses() {
  const sheet = SpreadsheetApp.getActiveSheet();
  const urls = sheet.getRange(2, 1, sheet.getLastRow() - 1, 1)
    .getValues().flat().filter(String);

  const out = [];
  for (let i = 0; i < urls.length; i += 50) {           // 50 requests per batch
    const batch = urls.slice(i, i + 50).map(url => ({
      url, muteHttpExceptions: true, followRedirects: false,
    }));
    UrlFetchApp.fetchAll(batch).forEach(res => {
      const h = res.getHeaders();
      out.push([res.getResponseCode(), h['Location'] || h['location'] || '']);
    });
  }
  sheet.getRange(2, 2, out.length, 2).setValues(out);
}

Three details make this fast and reliable. fetchAll sends a batch in parallel instead of one URL at a time. followRedirects: false lets you see the 301 or 308 itself rather than silently landing on the destination. muteHttpExceptions stops a single 404 from crashing the whole run. Results are written once with setValues, which is dozens of times faster than writing cell by cell.

Want a redirect chain instead of one hop? Run the script again on column C until it comes back empty, or loop inside the script up to five hops.

2. On-page audit: title, meta description, canonical, robots

Same input (URLs in column A), but this time the script reads the HTML and pulls the four tags that most often explain an indexing or snippet problem.

function auditOnPage() {
  const sheet = SpreadsheetApp.getActiveSheet();
  const urls = sheet.getRange(2, 1, sheet.getLastRow() - 1, 1)
    .getValues().flat().filter(String);
  const pick = (html, re) => { const m = html.match(re); return m ? m[1].trim() : ''; };

  const rows = UrlFetchApp.fetchAll(urls.map(url => ({ url, muteHttpExceptions: true })))
    .map(res => {
      const html = res.getContentText();
      const title = pick(html, /<title[^>]*>([^<]*)<\/title>/i);
      return [
        res.getResponseCode(),
        title,
        title.length,
        pick(html, /<meta[^>]+name=["']description["'][^>]+content=["']([^"']*)/i),
        pick(html, /<link[^>]+rel=["']canonical["'][^>]+href=["']([^"']*)/i),
        pick(html, /<meta[^>]+name=["']robots["'][^>]+content=["']([^"']*)/i),
      ];
    });
  sheet.getRange(2, 2, rows.length, rows[0].length).setValues(rows);
}

Columns B to G become status, title, title length, meta description, canonical and meta robots. Sort by canonical to find pages that point somewhere else, and filter robots for noindex. Two caveats. The regular expressions assume the attribute order most CMSs output (name before content, rel before href), so spot-check a few rows. And because Apps Script does not run JavaScript, tags that a single-page app injects in the browser will come back empty. That is itself a useful finding: crawlers that do not render JavaScript, including several AI crawlers, see the same empty value.

3. Pull Search Console data into a sheet

The Search Console interface caps exports at 1,000 rows. The API returns up to 25,000 rows per request, by page and query together. This script writes the last 28 days of data into a tab called GSC.

const SITE = 'sc-domain:example.com';   // or 'https://www.example.com/' for a URL-prefix property

function pullSearchConsole() {
  const day = 24 * 60 * 60 * 1000;
  const end = new Date(Date.now() - 3 * day);              // GSC data lags ~2-3 days
  const start = new Date(end.getTime() - 27 * day);
  const fmt = d => Utilities.formatDate(d, 'Etc/UTC', 'yyyy-MM-dd');

  const res = UrlFetchApp.fetch(
    'https://www.googleapis.com/webmasters/v3/sites/' + encodeURIComponent(SITE) +
      '/searchAnalytics/query',
    {
      method: 'post',
      contentType: 'application/json',
      headers: { Authorization: 'Bearer ' + ScriptApp.getOAuthToken() },
      payload: JSON.stringify({
        startDate: fmt(start), endDate: fmt(end),
        dimensions: ['page', 'query'], rowLimit: 25000,
      }),
      muteHttpExceptions: true,
    });
  if (res.getResponseCode() !== 200) throw new Error(res.getContentText());

  const rows = (JSON.parse(res.getContentText()).rows || []).map(r =>
    [r.keys[0], r.keys[1], r.clicks, r.impressions, r.ctr, r.position]);

  const ss = SpreadsheetApp.getActive();
  const sheet = ss.getSheetByName('GSC') || ss.insertSheet('GSC');
  sheet.clearContents();
  sheet.getRange(1, 1, 1, 6)
    .setValues([['page', 'query', 'clicks', 'impressions', 'ctr', 'position']]);
  if (rows.length) sheet.getRange(2, 1, rows.length, 6).setValues(rows);
}

Change SITE to your property exactly as Search Console shows it. Domain properties use the sc-domain: prefix; URL-prefix properties need the full URL with the trailing slash. If you hit the 25,000-row ceiling, add startRow: 25000 to the payload and call it again, appending the rows. Once the data is in Sheets, a pivot table of pages ranking between positions 8 and 20 with high impressions gives you the classic "almost there" list worth improving first.

4. Bulk index-status check with the URL Inspection API

When the Page indexing report shows hundreds of URLs as "Crawled – currently not indexed", you need to know which of your important pages are in that bucket. The URL Inspection API answers that one URL at a time, and Apps Script lets you run it down a whole column. Create a tab named Inspect with URLs in column A.

function inspectUrls() {
  const sheet = SpreadsheetApp.getActive().getSheetByName('Inspect');
  const urls = sheet.getRange(2, 1, sheet.getLastRow() - 1, 1)
    .getValues().flat().filter(String);

  const out = urls.map(url => {
    const res = UrlFetchApp.fetch(
      'https://searchconsole.googleapis.com/v1/urlInspection/index:inspect', {
        method: 'post',
        contentType: 'application/json',
        headers: { Authorization: 'Bearer ' + ScriptApp.getOAuthToken() },
        payload: JSON.stringify({ inspectionUrl: url, siteUrl: SITE }),
        muteHttpExceptions: true,
      });
    if (res.getResponseCode() !== 200) return ['ERROR ' + res.getResponseCode(), '', '', ''];
    const s = JSON.parse(res.getContentText()).inspectionResult.indexStatusResult;
    return [s.verdict, s.coverageState, s.lastCrawlTime || '', s.googleCanonical || ''];
  });
  sheet.getRange(2, 2, out.length, 4).setValues(out);
}

Column B is the verdict (PASS means indexed), column C the same coverage state you see in Search Console, column D the last crawl, and column E the canonical Google chose. Compare E with your own canonical from script 2: where they differ, Google is overruling you, usually because two pages are too similar. The API allows 2,000 inspections per property per day, so check your money pages and new content rather than the whole site.

5. PageSpeed Insights and Core Web Vitals for a list of URLs

The PageSpeed Insights API returns both the Lighthouse lab score and the real-user Core Web Vitals from the Chrome UX Report. Get a free API key in Google Cloud (APIs & Services → Credentials), then store it in Project Settings → Script properties as PSI_KEY so it never appears in your code.

function pageSpeed() {
  const key = PropertiesService.getScriptProperties().getProperty('PSI_KEY');
  const sheet = SpreadsheetApp.getActive().getSheetByName('Speed');
  const urls = sheet.getRange(2, 1, sheet.getLastRow() - 1, 1)
    .getValues().flat().filter(String);

  const out = urls.map(url => {
    const api = 'https://www.googleapis.com/pagespeedonline/v5/runPagespeed' +
      '?strategy=mobile&url=' + encodeURIComponent(url) + (key ? '&key=' + key : '');
    const res = UrlFetchApp.fetch(api, { muteHttpExceptions: true });
    if (res.getResponseCode() !== 200) return ['ERROR ' + res.getResponseCode(), '', '', ''];
    const j = JSON.parse(res.getContentText());
    const m = (j.loadingExperience && j.loadingExperience.metrics) || {};
    const p = k => (m[k] ? m[k].percentile : '');
    const cls = p('CUMULATIVE_LAYOUT_SHIFT_SCORE');
    return [
      Math.round(j.lighthouseResult.categories.performance.score * 100), // lab score 0-100
      p('LARGEST_CONTENTFUL_PAINT_MS'),                                  // field LCP, ms
      p('INTERACTION_TO_NEXT_PAINT'),                                    // field INP, ms
      cls === '' ? '' : cls / 100,                                       // field CLS
    ];
  });
  sheet.getRange(2, 2, out.length, 4).setValues(out);
}

Each PageSpeed call takes 10 to 30 seconds, so a single run handles roughly 10 to 20 URLs before the six-minute limit. Keep the Speed tab to your key templates (home, a category, a product, an article) rather than every URL. Field data is empty for pages without enough Chrome traffic; that is normal, and the lab score still tells you which template is slow. The thresholds Google treats as "good" are LCP under 2,500 ms, INP under 200 ms and CLS under 0.1.

6. Run it every week and email yourself when something breaks

A report nobody re-runs goes stale within a month. Time-driven triggers fix that. Run installTriggers once and Google will refresh the data every Monday morning and check index status every day.

function installTriggers() {
  ScriptApp.newTrigger('pullSearchConsole')
    .timeBased().onWeekDay(ScriptApp.WeekDay.MONDAY).atHour(6).create();
  ScriptApp.newTrigger('dailyIndexCheck')
    .timeBased().everyDays(1).atHour(7).create();
}

function dailyIndexCheck() {
  inspectUrls();
  const rows = SpreadsheetApp.getActive().getSheetByName('Inspect')
    .getDataRange().getValues().slice(1);
  const notIndexed = rows.filter(r => r[0] && r[1] !== 'PASS')
    .map(r => r[0] + ' : ' + r[2]);
  if (notIndexed.length) {
    MailApp.sendEmail(Session.getEffectiveUser().getEmail(),
      notIndexed.length + ' important pages are not indexed',
      notIndexed.join('\n'));
  }
}

Run installTriggers only once: every run adds another set of triggers. You can see and delete triggers under the clock icon in the editor. Triggers run as you, so the script keeps working while the sheet is closed.

Quotas and limits that will eventually bite

These are the limits that matter for SEO work. Google publishes the full list on its Apps Script quotas page and changes it occasionally, so check there before you design something big.

LimitFree Gmail accountGoogle Workspace
Runtime per execution6 minutes6 minutes
Custom function (=MYFUNCTION()) runtime30 seconds30 seconds
URL Fetch calls per day20,000100,000
Triggers per user per script2020
Search Analytics rows per request25,000 (page through with startRow)
URL Inspection API2,000 per property per day, 600 per minute

The six-minute limit is the one you hit first. The standard workaround is to process URLs in chunks, store the next row to process in PropertiesService, and let a time-driven trigger pick up where the last run stopped. If you find yourself building that for every script, you have outgrown Apps Script.

Apps Script vs Python vs a crawler

JobBest toolWhy
Check a list of up to a few thousand URLsApps ScriptResults land in the sheet; nothing to install
Weekly GSC, URL Inspection or PageSpeed pullsApps ScriptFree scheduling, OAuth handled for you
Crawl a whole site, render JavaScriptCrawler (Screaming Frog, Sitebulb)Apps Script cannot render and will time out
Join millions of rows, log-file analysis, MLPython or BigQueryNo runtime cap, real data libraries

A common path is to start in Apps Script, prove the report is worth having, and move it to Python or BigQuery once the data or the runtime outgrows it.

Writing Apps Script with AI, and what to check

ChatGPT, Claude and Gemini all write workable Apps Script if you describe the sheet precisely: which tab, which column holds the input, which columns the output goes to, and which API to call. Before you run what they give you, check four things, because they are the mistakes AI-written scripts make most often:

It writes in one batch. Look for a single setValues at the end, not setValue inside a loop.
It survives errors. Every UrlFetchApp.fetch should have muteHttpExceptions: true, and the code should check the response code.
No secrets in the code. API keys belong in Script properties, not in a string you might share with the sheet.
It respects quotas. Anything that loops over more than a few hundred API calls needs batching or a resume mechanism.

A spreadsheet can tell you how Google ranks and indexes your pages. It cannot tell you whether ChatGPT, Perplexity or Google's AI Mode mention your brand when people ask about your category, because those answers are generated per prompt and change daily. That needs answer tracking across engines, which is what our AI visibility tracker does.

Mistakes to avoid

Using a custom function for hundreds of URLs. =HTTPSTATUS(A2) dragged down 500 rows means 500 separate executions, each capped at 30 seconds, and Sheets re-runs them whenever it recalculates. Use a menu-run function with fetchAll instead.
Writing one cell at a time. Each setValue is a round trip; 1,000 of them can take longer than the six-minute limit.
Letting one bad URL kill the run. Without muteHttpExceptions, the first 404 or 500 throws and you lose everything fetched so far.
Hammering an API. If you get HTTP 429, pause with Utilities.sleep(1000) between calls and retry. Do not just run it again in a tight loop.
Trusting raw HTML on JavaScript sites. If titles or canonicals come back empty, check whether the site renders them in the browser before you report them as missing.

Questions people ask

What is Google Apps Script used for in SEO?
Mostly for bulk checks and scheduled reporting inside Google Sheets: checking status codes and redirects for a list of URLs, extracting titles, meta descriptions and canonicals, pulling Search Console performance data beyond the 1,000-row export limit, bulk-checking index status with the URL Inspection API, and collecting PageSpeed and Core Web Vitals data, all refreshed automatically with time-driven triggers.
Can Apps Script pull data from Google Search Console?
Yes. Add the webmasters.readonly scope to the project manifest and call the Search Analytics endpoint with the token from ScriptApp.getOAuthToken(). Each request returns up to 25,000 rows by page, query, country, device or date, and you can page through larger results with startRow. The same token works for the URL Inspection API, which reports index status for individual URLs.
Is Apps Script free?
Yes. It is included with every Google account. Free accounts have lower daily quotas than Google Workspace accounts, for example 20,000 URL fetches a day instead of 100,000, but every execution is limited to six minutes on both.
Should I use Apps Script or Python for SEO automation?
Use Apps Script when the input and output live in a Google Sheet, the list is a few thousand URLs or fewer, and the job is an API pull or a simple check. Use Python when you need to crawl or render pages, process very large datasets, run longer than six minutes, or use data-analysis libraries.
Can Apps Script check if a page is indexed?
Yes, through the Search Console URL Inspection API. For each URL it returns a verdict (PASS when indexed), the coverage state such as "Crawled - currently not indexed", the last crawl time and the canonical Google selected. The limit is 2,000 inspections per property per day.

Related from GetCited

Content Engine

Create content purpose-built for AI citation, from brief to published page.

Explore Content Engine

AI Visibility Tracker

Monitor how AI engines cite your brand across ChatGPT, Perplexity, Gemini, and more.

Explore AI Visibility Tracker

Competitor Gap Intelligence

See where competitors get cited by AI engines and find the gaps you can win.

Explore Competitor Gap Intelligence
Next step

AI visibility is half the journey. Let us show you the other half.