Eric Hinzpeter
NOTE· 2026-07-25· 7 min

Automated SEO Reports with Google Search Console and n8n

A daily email that compares seven days against the seven before, flags the keywords worth a nudge, and tells you when your traffic is quietly dying. No AI involved.

Be honest: when did you last open Google Search Console? For most people the answer is "when traffic dropped." Which is exactly the wrong time, because by then Google has already spent weeks making a decision about your site and you're reading the receipt.

I built a small n8n workflow that flips this around. Every morning at seven I get an email with the numbers that matter, already compared against the week before. Six nodes, and no AI anywhere in it.

What lands in the inbox

The report is one dark-themed HTML email. No attachment, no login.

It opens with the overview: clicks, impressions, CTR and average position for the last seven days, each carrying a percentage delta against the seven days before. Position gets its colours flipped, since a smaller number is the good direction there.

Under that sit the keyword tables. Top keywords are sorted by impressions rather than clicks, because on a small site the click counts are far too sparse to rank anything sensibly. The opportunities table holds everything between position 4 and 20, so bottom of page one through page two, which is where a rewritten title or a couple of internal links actually moves something. Hidden gems are the keywords with impressions but no clicks at all: Google is showing you and nobody bites, so the problem is your title and description rather than your ranking.

Then cannibalization, meaning the same query ranking with two or more of your own URLs, listed only when that second URL takes a real share of the impressions. And last a topic cluster block that groups keywords by shared word pairs, which is a rough but useful read on what Google thinks your site is about.

Setting up Google Cloud

This is the fiddly part. Google doesn't make it pleasant, but it's twenty minutes once.

  1. Open the Google Cloud Console and log in with the account that has access to your Search Console property.
  2. Create a new project.
  3. Under APIs & Services → Library, enable the Google Search Console API. If you want the email sent through Gmail, enable the Gmail API too.
  4. Configure the OAuth consent screen. External user type is fine. Add your own Google account under test users, otherwise you'll be stuck in the verification loop.
  5. Under Credentials, create an OAuth client ID of type Web application, and paste n8n's OAuth redirect URL into the authorized redirect URIs.

Then create the matching credentials in n8n: a Google OAuth2 API credential for the Search Console calls, and a Gmail OAuth2 one for sending. Both nodes reference these by name, so you set them once.

The workflow, node by node

1. Schedule trigger

Plain daily trigger at 07:00. Nothing to configure beyond the hour.

2. Work out the two date windows

A Code node, and the first place where the details matter:

const LAG_DAYS = 3;
const WINDOW_DAYS = 7;

const DAY_MS = 86400000;
const iso = (d) => d.toISOString().slice(0, 10);

const base = new Date(Date.now() - LAG_DAYS * DAY_MS);
const curEnd = new Date(Date.UTC(base.getUTCFullYear(), base.getUTCMonth(), base.getUTCDate()));
const curStart = new Date(curEnd.getTime() - (WINDOW_DAYS - 1) * DAY_MS);
const prevEnd = new Date(curStart.getTime() - DAY_MS);
const prevStart = new Date(prevEnd.getTime() - (WINDOW_DAYS - 1) * DAY_MS);

LAG_DAYS = 3 is the important line. Search Console data arrives late, and the most recent two or three days are always incomplete. Include them and your report shows a decline every morning that isn't real. I'd rather read a number that's three days old and correct.

Normalizing to UTC midnight matters too, otherwise your windows shift depending on where the n8n instance runs.

3. Pull the current period

An HTTP Request node, POST to:

https://searchconsole.googleapis.com/webmasters/v3/sites/sc-domain:YOUR-DOMAIN/searchAnalytics/query

Authentication is set to a predefined credential type, Google OAuth2 API. Body as JSON:

{
  "startDate": "{{ $json.startDate }}",
  "endDate": "{{ $json.endDate }}",
  "dimensions": ["query", "page"],
  "type": "web",
  "aggregationType": "auto",
  "rowLimit": 5000,
  "startRow": 0
}

Two dimensions, query and page. You need the page dimension for the cannibalization check, since that's the whole point: one query, several URLs. Turn on retry on fail with three attempts, because the API does hand out the occasional 429.

4. Pull the previous period

Same node, same body, different dates. It reads them back from the first Code node, referenced by whatever you named it (mine is called Date window):

{{ $('Date window').first().json.prevStartDate }}

Two separate calls rather than one clever one. The API doesn't do period comparison, so you fetch twice and do the maths yourself.

5. Build the report

The second Code node does the actual work. Three things in it are worth stealing.

Aggregate to query level first. The API returns query-by-page pairs, so a single keyword shows up several times if it ranks with several URLs. Fold those into one entry per query before you compare anything. Clicks and impressions just add up. Position has to be weighted by impressions, which is how the Search Console UI computes it too:

entry.position = entry.impressions > 0 ? entry.posWeighted / entry.impressions : 0;

Make the thresholds relative. My first version used fixed cutoffs, something like "at least 50 impressions and no clicks" for the hidden gems card. On a site my size that card was empty every single day. Now it takes the 80th percentile of my own impression counts:

const gemThreshold = Math.max(2, percentile(curQueries.map((q) => q.impressions), 0.8));

Same idea for cannibalization: the second URL has to reach at least 20% of the top URL's impressions before I care. Relative thresholds scale with the site instead of assuming one.

Deduplicate the topic clusters. Grouping keywords by word pairs is cheap and works surprisingly well, but the pairs from one keyword overlap heavily. "lightroom export" and "export settings" produce nearly the same cluster. So each candidate cluster gets compared against the ones already picked using Jaccard similarity, and anything above 50% overlap is dropped:

const jaccard = (a, b) => {
  const overlap = [...a].filter((x) => b.has(x)).length;
  return overlap / new Set([...a, ...b]).size;
};

Three clusters make the cut. More than that and the email turns into a wall.

The node returns the finished HTML plus a subject line carrying the headline numbers, so the inbox preview already tells you whether anything moved.

6. Send it

Gmail node, email type HTML, subject and body from the previous node. Turn off "append attribution" unless you want n8n's footer in your own report.

What it actually caught

This is how I found out my own site had gone quiet. Impressions fell from roughly 150 a week to 8, and the report put that in front of me with the comparison already done. The cause turned out to be a hosting migration that had silently killed every redirect on the site, which is not the kind of thing you notice by looking at your homepage.

Which is the real argument for a daily report rather than a monthly check. You won't act on it most mornings, but in the week where it matters you're already looking at the right screen.

Where it's weak

Word-pair clustering is a blunt instrument. It groups by surface form, so two keywords meaning the same thing in different words end up in different clusters. An embedding model would do better, and I've deliberately not used one, since that trades a free workflow for an API bill on something I read for ten seconds a day.

And it won't tell you why something changed. It tells you that it changed and hands you the URL, and then you still have to go and work out what happened, which in my case took a morning of curling old URLs.

FAQ

Why does the report skip the last three days?
Search Console data lags. The most recent two or three days are incomplete, and if you include them your comparison shows a fake decline every single morning. The workflow sets LAG_DAYS to 3 and ends the current window there.
Do I need an AI model for this?
No. Everything here is plain JavaScript in two Code nodes. Grouping keywords into topics uses word pairs and a set-overlap check, not embeddings. That keeps the workflow free to run and makes every number reproducible.
Does this work for a site with almost no traffic?
Yes, that's what the relative thresholds are for. The 'hidden gems' card takes the 80th percentile of your own impression counts instead of a fixed minimum, so it still surfaces something when your best keyword has twelve impressions.

Written by

Portrait of Eric Hinzpeter

Eric Hinzpeter

Eric Hinzpeter, Senior B2B Content Strategist. He builds production AI agents and marketing automation, and documents the results here.

AboutLinkedIn