advertools: the Python toolkit that replaces a drawer of SEO scripts
An open-source Python package for crawling, sitemap and robots.txt analysis, SERP tooling and log-file parsing, all under one pip install.
The SEO Bench
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.
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./**
* 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);
}
SITE_URL to your property.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).main once and authorise the script when prompted with the Google account that can read the property.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.
More Like This
An open-source Python package for crawling, sitemap and robots.txt analysis, SERP tooling and log-file parsing, all under one pip install.
A community-maintained, continuously updated list of AI crawlers, shipped as drop-in robots.txt, htaccess, Nginx and Caddy files so you can block or allow AI bots in minutes.
A Cloudflare feature, free on every plan, that shows exactly which AI crawlers hit your site and lets you allow or block them per bot with one click.