DEV Community

Cover image for Your Search Console script is undercounting and it will never tell you
Ibrahim Hajjaj
Ibrahim Hajjaj

Posted on

Your Search Console script is undercounting and it will never tell you

I have shipped both of these bugs. So, I would guess, has anyone who has written more than about fifty lines against the Google Search Console API. Neither throws, neither logs, and both produce numbers confident enough to put in a report.

They are the same mistake wearing two costumes: treating a row that is not there as a row whose value is zero.

Bug one: summing a dimension Google censors

You want clicks for last month. The obvious call gives you a breakdown by query, so you add up the clicks column.

const { data } = await searchconsole.searchanalytics.query({
  siteUrl,
  requestBody: { startDate, endDate, dimensions: ["query"] },
});
const clicks = data.rows.reduce((sum, r) => sum + r.clicks, 0);  // wrong
Enter fullscreen mode Exit fullscreen mode

Search Console does not return every query. It withholds queries issued by very few users, as a privacy measure, and it does not tell you how many it held back or how much traffic they carried. The rows you get are real. The set is incomplete, and incomplete by a different amount every window.

So that sum is not "clicks last month". It is "clicks from the queries Google felt comfortable showing me last month", which is a smaller number that moves for reasons having nothing to do with your site.

Now do the thing everyone does next and compare two months. You have manufactured a trend out of Google's disclosure policy. A month where more of your traffic came from long-tail queries looks like a month where you lost traffic.

The fix is one line and completely unobvious:

// Clicks and impressions come from the date dimension. Summing the query
// dimension undercounts, because Search Console withholds low-volume queries,
// and that gap reads later as a real decline rather than as withheld rows.
const byDate = await searchAnalytics({ ...base, dimensions: ["date"], rowLimit: 500 });
Enter fullscreen mode Exit fullscreen mode

Totals come from the date dimension. Always. The query and page dimensions are for "what are my top queries", never for "how much did I get". If you want both, that is two calls, and they will not reconcile, and the difference between them is the censored tail rather than a bug in your code.

Bug two: a cut-off list read as a complete one

This one is worse, because it does not undercount. It fabricates an event.

Search Console caps rows per query and returns them ordered by clicks, descending. My insight tools asked for 5,000 rows, got 5,000 rows, and said nothing about it:

function insightRequest(window, dimensions) {
  return { ...window, dimensions, rowLimit: 5000 };  // wrong
}
Enter fullscreen mode Exit fullscreen mode

Sitting on a site with more than 5,000 rows, that cut lands exactly on the low-click rows. Which are exactly the rows a striking-distance report, a CTR-gap report and a cannibalisation report exist to find. The tools were reliably blind in precisely their own subject area, and nothing said so.

Then the period comparison joined two of those lists:

const joined = new Set([...currentByKey.keys(), ...previousByKey.keys()]);
// for each key: (current?.clicks ?? 0) - (previous?.clicks ?? 0)
Enter fullscreen mode Exit fullscreen mode

?? 0. There it is again.

A query that ranked 4,900th last month and 5,100th this month is absent from the current list. It is scored as zero clicks. The tool reports it as having lost every click it had, ranks it near the top of the losers table, and hands you a collapse that never happened. From the tool whose only job is telling you what changed.

The fix is a probe row and a third state

Ask for one row more than you intend to analyse. That single extra row is the only thing that separates "there were exactly this many" from "the list was cut":

const INSIGHT_ROW_LIMIT = 5000;

function insightRequest(window, dimensions) {
  // The one extra row is the probe.
  return { ...window, dimensions, rowLimit: INSIGHT_ROW_LIMIT + 1 };
}

async function fetchInsightRows(clients, siteUrl, requestBody) {
  const response = await clients.searchConsole.searchanalytics.query({ siteUrl, requestBody });
  const fetched = response.data.rows ?? [];
  const rows = fetched.slice(0, INSIGHT_ROW_LIMIT).map(/* ... */);
  return { rows, truncated: fetched.length > INSIGHT_ROW_LIMIT };
}
Enter fullscreen mode Exit fullscreen mode

The probe row is never analysed. It exists to answer one boolean.

Then that boolean has to change the arithmetic, not just add a footnote:

for (const key of joinedKeys) {
  const currentRow = currentByKey.get(key);
  const previousRow = previousByKey.get(key);
  // A cut-off list says nothing about the rows below the cut, so a key absent
  // from a truncated side is unknown there, not zero. Scoring it as zero turns
  // a row that merely slipped past the cut into a total gain or total loss.
  if ((currentRow === undefined && currentTruncated)
   || (previousRow === undefined && previousTruncated)) {
    droppedAsUnknown += 1;
    continue;
  }
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Note that it is conditional on truncation. If the list came back complete, an absent key genuinely is zero and should be reported as a real loss. The correction only applies where the data actually stopped.

And the count of what was withheld goes in the output, because a tool that silently drops rows to avoid lying has just found a quieter way to lie:

Note: Search Console returned more than 5000 rows for the current window,
so a key missing from a cut-off window is unknown there rather than lost;
keys left out of this comparison for that reason: 37.
Enter fullscreen mode Exit fullscreen mode

The rule underneath both

Two states are not enough. You need three.

  • present with a value
  • absent, and you know the list was complete, so absent means zero
  • absent, and the list was cut off or censored, so absent means unknown

?? 0 collapses the second and third into each other. It is a default value standing in for a fact you do not have, and it is invisible because the type system is perfectly happy: number | undefined becomes number and everything downstream typechecks and runs and produces a chart.

Every API that paginates, ranks, samples, or withholds for privacy has this shape. Search Console does all four.

The other thing Search Console will not do for you

Its window is 16 months, rolling. Whatever last spring looked like is gone, and no API call brings it back. You cannot answer "did the change I made six weeks ago work" unless six weeks ago you thought to write the numbers down.

That is most of why I built the tool this code lives in. It snapshots Search Console, App Store listings, Play installs and wordpress.org active installs to files you keep, so a future you has a before to compare against.

It is seo-console-mcp, MIT, an MCP server and a CLI over the same tool registry. Four of the tools run with no credentials at all if you want to try it without the service-account dance.

Yes, a good chunk of it is a wrapper around an API. The part that is not a wrapper is everything above: a missing row is unknown rather than zero, a truncated list says it was truncated, a failed surface is recorded in place rather than dropped from the output, and totals never come from summing a dimension Google censors.

Top comments (1)

Collapse
 
ibrahimwithi profile image
Ibrahim Hajjaj •

one thing the post doesnt cover, and its the MCP part

truncated: true fixes the math, not the reader. whats reading my output is usually a model, and a model will happily tell you traffic dropped 40% with truncated: true sitting right there, because nobody asked it about that field

so the note goes in the text too:

Note: Search Console returned more than 5000 rows for this window, so this list was computed from the top 5000 by clicks; a row that is absent here is unknown rather than absent.
Enter fullscreen mode Exit fullscreen mode

code ignores what it wasnt told to read. a model ignores what doesnt look like the answer. so it goes in both and yes it looks repetitive

what i still dont have: if the model summarises this into a report for someone, the note is one step from gone. refusing to answer when a list is truncated would make it useless on the big sites that need it most. if anyone has a better idea im listening

MIT, github.com/ibrahimhajjaj/seo-console-mcp