★ Community Edition ★Price: Free



The SEO Bench

GSC winners and losers: the 28-day click swing, written to a Sheet

A Google Apps Script that reads Search Console for the last 28 days against the prior 28, computes the click deltas by query and page, and writes Winners and Losers tabs.

The Search Console interface will happily show you the last 28 days, and it will happily show you the prior 28 days, but it will not sit them side by side and tell you what moved. So the queries quietly bleeding clicks stay invisible until a quarterly review, by which point the decay is a trend rather than a blip. This script does the comparison every time you run it.

It queries the Search Console API for two consecutive 28-day windows, once by query and once by page, works out the click change for each, sorts the gainers and the fallers, and writes them to two tabs in the Sheet it is bound to. It reads only: no property settings are touched, nothing is submitted, no URL is inspected or removed. It reports the arithmetic the interface makes you do by eye.

Before you start

  • You need read access to the property and a Google Sheet to hold the output. The script is bound to that Sheet, so create it first.
  • Set SITE_URL to your property exactly as Search Console stores it: a Domain property is sc-domain:example.com, a URL-prefix property is the full https://example.com/ including the protocol and trailing slash.
  • Data older than about three days is still settling, so the most recent window is always slightly provisional. Treat single-day swings as noise.
  • The script needs three OAuth scopes declared in the project manifest. See the setup note below.

The script

JavaScript
/**
 * GSC winners and losers
 * The SEM Dispatch Vault, semdispatch.com/vault
 *
 * Compares the last 28 days of Search Console clicks with the prior 28,
 * by query and by page, and writes Winners and Losers tabs to the bound Sheet.
 * Read-only: this script never changes the property or submits anything.
 */

var CONFIG = {
  // Your property, exactly as Search Console stores it.
  // Domain property:     "sc-domain:example.com"
  // URL-prefix property: "https://example.com/"
  SITE_URL: "sc-domain:example.com",

  // How many rows to keep per tab, per direction.
  ROW_LIMIT: 50
};

function main() {
  var recent = windowDates(0);   // last 28 days, ending 3 days ago
  var prior = windowDates(28);   // the 28 days before that

  buildTab("Winners and losers by query", "query", recent, prior);
  buildTab("Winners and losers by page", "page", recent, prior);
}

function windowDates(offsetDays) {
  var MS_DAY = 24 * 60 * 60 * 1000;
  // End 3 days back so the freshest, still-settling data is excluded.
  var end = new Date(Date.now() - (3 + offsetDays) * MS_DAY);
  var start = new Date(end.getTime() - 27 * MS_DAY);
  return { start: isoDate(start), end: isoDate(end) };
}

function isoDate(d) {
  return Utilities.formatDate(d, "UTC", "yyyy-MM-dd");
}

function buildTab(tabName, dimension, recent, prior) {
  var recentRows = queryClicks(dimension, recent);
  var priorRows = queryClicks(dimension, prior);

  var deltas = {};
  Object.keys(recentRows).forEach(function (key) {
    deltas[key] = (deltas[key] || 0) + recentRows[key];
  });
  Object.keys(priorRows).forEach(function (key) {
    deltas[key] = (deltas[key] || 0) - priorRows[key];
  });

  var rows = Object.keys(deltas).map(function (key) {
    return {
      key: key,
      now: recentRows[key] || 0,
      before: priorRows[key] || 0,
      change: deltas[key]
    };
  });

  var winners = rows
    .filter(function (r) { return r.change > 0; })
    .sort(function (a, b) { return b.change - a.change; })
    .slice(0, CONFIG.ROW_LIMIT);

  var losers = rows
    .filter(function (r) { return r.change < 0; })
    .sort(function (a, b) { return a.change - b.change; })
    .slice(0, CONFIG.ROW_LIMIT);

  writeTab(tabName, dimension, winners, losers);
}

function queryClicks(dimension, range) {
  var token = ScriptApp.getOAuthToken();
  var url = "https://www.googleapis.com/webmasters/v3/sites/" +
    encodeURIComponent(CONFIG.SITE_URL) + "/searchAnalytics/query";

  var payload = {
    startDate: range.start,
    endDate: range.end,
    dimensions: [dimension],
    rowLimit: 5000
  };

  var response = UrlFetchApp.fetch(url, {
    method: "post",
    contentType: "application/json",
    headers: { Authorization: "Bearer " + token },
    payload: JSON.stringify(payload),
    muteHttpExceptions: true
  });

  if (response.getResponseCode() !== 200) {
    throw new Error(
      "Search Console API said " + response.getResponseCode() +
      ": " + response.getContentText()
    );
  }

  var data = JSON.parse(response.getContentText());
  var out = {};
  (data.rows || []).forEach(function (row) {
    out[row.keys[0]] = row.clicks;
  });
  return out;
}

function writeTab(tabName, dimension, winners, losers) {
  var ss = SpreadsheetApp.getActiveSpreadsheet();
  var sheet = ss.getSheetByName(tabName) || ss.insertSheet(tabName);
  sheet.clear();

  var header = [dimension, "Clicks now", "Clicks before", "Change"];
  var block = [];
  block.push(["WINNERS", "", "", ""]);
  block.push(header);
  winners.forEach(function (r) {
    block.push([r.key, r.now, r.before, "+" + r.change]);
  });
  block.push(["", "", "", ""]);
  block.push(["LOSERS", "", "", ""]);
  block.push(header);
  losers.forEach(function (r) {
    block.push([r.key, r.now, r.before, r.change]);
  });

  sheet.getRange(1, 1, block.length, 4).setValues(block);
}

Set it up

  1. Create the Google Sheet that will hold the output, then open Extensions, then Apps Script from inside it (the script must be bound to the Sheet).
  2. Delete the placeholder, paste the code above, and set SITE_URL to your property.
  3. Declare the OAuth scopes the script needs. In the editor open Project Settings, tick "Show appsscript.json manifest file", then add to the manifest an oauthScopes array containing https://www.googleapis.com/auth/webmasters.readonly (read Search Console), https://www.googleapis.com/auth/script.external_request (call the API with UrlFetchApp), and https://www.googleapis.com/auth/spreadsheets (write the tabs).
  4. Run main once and authorise the script when prompted with the Google account that can read the property.
  5. Optionally add a time-driven trigger to run it weekly, so the tabs are current when you sit down to review.

How to read the output

The two tabs answer different questions. The query tab shows which searches you are winning or losing; the page tab shows which URLs. A query and its page usually move together, and when they do not, that gap is the interesting part: a page holding clicks while its head query falls is picking up the slack elsewhere, and is worth a look before you touch it.

Two honest limitations. The comparison is clicks only, so a query that lost clicks purely because seasonal demand fell will show as a loser even though nothing on your side changed; cross-check against impressions in the interface before you act. And the windows exclude the last three days because that data is still settling, so "the last 28 days" here ends three days ago, not today. This is a diagnostic that tells you where to look, not a verdict on why.

  • Search Console
  • Reporting
  • Content Decay