Search Console told us one of our provider hub pages had an average position of 7.42 over the last three months. Page one, comfortably.
A scripted check of the live result page for that provider's head term found the page nowhere in the top 30.
Both numbers are correct. They are answers to different questions, and confusing them sends you off to fix things that are not broken while the actual problem sits in plain sight.
What average position is a statistic about
Average position is computed over every query the page appeared for, weighted by the impressions each query generated. It is a property of your query mix.
That page had 24 impressions in three months. Twenty four. Whatever queries produced them were specific enough that almost nobody types them, and on those queries the page did well, because there is barely any competition for a phrase nobody searches. Averaging over them produces 7.42, and 7.42 means "we win the queries we win", which is not information.
Now the opposite case, same site. Another hub had 3,806 impressions and an average position of 36.82. By the dashboard number it looks far worse. It is not: it is being shown, thousands of times, for queries people actually search, and it is losing them on page four. That page has a demand problem you can act on. The 7.42 page has no demand at all.
Here is the pattern across the whole set, trimmed to the rows that make the point:
| Hub | Live position, head term | GSC impressions | GSC clicks | GSC avg position |
|---|---|---|---|---|
| arctic-shores | 4 | 18,632 | 1,491 | 8.07 |
| saville | 7 | 457 | 2 | 16.52 |
| pymetrics | 12 | 3,979 | 127 | 18.14 |
| hirevue | >30 | 24 | 2 | 7.42 |
| acer | >30 | 39 | 2 | 7.51 |
| shl | >30 | 3,806 | 16 | 36.82 |
| sova | >30 | 139 | 3 | 8.72 |
Sort that table by average position and you get a ranking of how obscure your traffic is. Sort it by impressions and you get a ranking of where the opportunity is. Only the first column tells you whether you are actually competing for the phrase you built the page for.
Why the live check needs a real browser
The second column does not come from an API. Getting it means loading a search result page the way a person does.
Plain HTTP to a search engine gets a consent wall or a redirect, not results. Signed-in browsing gets personalised results, which are not the results anyone else sees. So the check runs through a scripted Chromium session with the knobs that remove the obvious sources of noise:
await page.goto(
"https://www.google.com/search?q=" + encodeURIComponent(q) +
"&hl=en&gl=us&pws=0&start=" + start
);
hl=en fixes the interface language, gl=us fixes the market, pws=0 turns off personalisation, and start walks three pages so "not in the top 30" is a measured result rather than a guess about page one.
Extracting the rank is one DOM pass. Organic results are the links that contain a heading, which filters out the ads, the site links and the "people also ask" block in one condition:
const links = await page.evaluate(() => {
const out = []; const seen = new Set();
for (const a of document.querySelectorAll("a[href^='http']")) {
if (!a.querySelector("h3")) continue;
const href = a.href.split("#")[0];
if (seen.has(href) || href.includes("google.com")) continue;
seen.add(href); out.push(href);
}
return out;
});
const i = links.findIndex((h) => h.includes("cogniprep"));
Two behaviours matter more than the parsing.
It waits between queries, with jitter. 2500 + Math.random() * 2000 between page loads. A loop that fires 24 queries as fast as the network allows is a loop that gets blocked at query nine and teaches you nothing.
It stops when it is told to stop. If the URL after navigation contains /sorry/, the run records BLOCKED for that row and breaks out of the whole loop rather than hammering on:
if ((await page.url()).includes("/sorry/")) { found = "BLOCKED"; break; }
One row in our table says blocked for exactly this reason, and leaving it visibly blank is better than a number nobody can trust. A measurement script that hides its own failures is worse than no script, because it converts "I do not know" into a confident wrong answer.
The output is a file in the repo, not a dashboard
The whole audit is 38 lines of markdown committed next to the code, with the date in the filename, the exact query template it used, the settings it used, and a line at the bottom saying when to re-run it. It records what changed on the same day, so the next reader knows which table row was measured before which deploy.
That is deliberate. Ranking work has a feedback loop measured in weeks, and by the time the result arrives the person reading it has forgotten what the baseline conditions were. A dashboard shows you today. A dated file in the repo shows you the delta, and it survives losing access to the dashboard.
What it actually changed
The mix of columns split the 24 pages into three piles, and each pile got a different action:
- High impressions, poor live position. Real demand, losing the ranking. Rewrite the title, description and H1 into the phrasing people actually search, which was not the phrasing we had chosen.
- Low impressions, flattering average position. No demand reaching the page. Nothing on the page itself will fix that; it needs links from pages that do get traffic.
- Good on both. Leave it alone.
Without the live column, the second pile looks like the healthiest set of pages on the site.
See it: pick a provider and search arctic shores assessment practice yourself in a private window, then compare against cogniprep.app/games/arctic-shores. Do the same for saville assessment practice and cogniprep.app/games/saville. Then try a provider from the bottom of the table and watch a page that Search Console rates at 7.4 fail to appear at all. The gap between those two experiences is the entire post.
Top comments (0)