Striking-distance keywords are the queries where your site already shows up, just not quite high enough: average positions somewhere between 8 and 20, with real impressions behind them. They are often the cheapest SEO wins you have, because Google has already decided the page is relevant. The Search Console performance report can show them, but it caps exports at 1,000 rows and makes it awkward to ask the one question that matters: which URL is ranking for each query, and is it the only one?
The Search Console API answers that question in a few dozen lines of Node. Below is a small script for it, and the reasoning behind its thresholds.
Setup: a read-only service account
- In Google Cloud, create a project, enable the Google Search Console API and create a service account with a JSON key.
- In Search Console, open the property settings and add the service account's email address as a user. Restricted permission is enough for reading performance data.
- Install the client library and point it at the key:
npm install googleapis
export GOOGLE_APPLICATION_CREDENTIALS=./gsc-key.json
export GSC_SITE="sc-domain:example.com"
Use sc-domain:example.com for a domain property or the full URL with trailing slash (https://www.example.com/) for a URL-prefix property. The only scope the script needs is webmasters.readonly. When I built the Search Console connection for Crawl Foundry, the SEO tool I work on in my spare time, it requests exactly that scope and nothing else. A reporting script has no business holding write access to your property.
Pulling query and page rows
The key decision is the dimensions. Requesting query alone gives you one average position per query across all of your URLs. Requesting query and page gives you one row per URL that appeared for that query, which is what you need to see which page Google picked and whether several pages compete.
// striking-distance.mjs
import { google } from "googleapis";
const auth = new google.auth.GoogleAuth({
scopes: ["https://www.googleapis.com/auth/webmasters.readonly"],
});
const gsc = google.searchconsole({ version: "v1", auth });
function daysAgo(n) {
const d = new Date();
d.setUTCDate(d.getUTCDate() - n);
return d.toISOString().slice(0, 10);
}
async function fetchRows(startDate, endDate) {
const rows = [];
for (let startRow = 0; ; startRow += 25000) {
const res = await gsc.searchanalytics.query({
siteUrl: process.env.GSC_SITE,
requestBody: {
startDate,
endDate,
type: "web",
dimensions: ["query", "page"],
rowLimit: 25000,
startRow,
},
});
const batch = res.data.rows ?? [];
rows.push(...batch);
if (batch.length < 25000) return rows;
}
}
rowLimit tops out at 25,000 per request, so the loop pages with startRow until a short page comes back. The window ends three days ago because the most recent days are still incomplete, and a four-week window evens out weekday swings.
Filtering for striking distance
const rows = await fetchRows(daysAgo(31), daysAgo(3));
const byQuery = new Map();
for (const row of rows) {
const [query, page] = row.keys;
const pages = byQuery.get(query) ?? [];
pages.push({ page, impressions: row.impressions, clicks: row.clicks, position: row.position });
byQuery.set(query, pages);
}
const candidates = [];
for (const [query, pages] of byQuery) {
pages.sort((a, b) => b.impressions - a.impressions);
const lead = pages[0];
const impressions = pages.reduce((sum, p) => sum + p.impressions, 0);
if (impressions < 100) continue;
if (lead.position < 8 || lead.position > 20) continue;
candidates.push({
query,
page: lead.page.replace(/^https?:\/\/[^/]+/, ""),
position: lead.position.toFixed(1),
impressions,
clicks: pages.reduce((sum, p) => sum + p.clicks, 0),
pages: pages.length,
});
}
candidates.sort((a, b) => b.impressions - a.impressions);
console.table(candidates.slice(0, 30));
Run it with node striking-distance.mjs and you get a table sorted by impressions, which is a rough proxy for how much traffic page one could bring.
Why the thresholds look like this
The lead page, not the query average. For each query the script takes the URL with the most impressions and uses its position. That is the page you would actually improve.
A minimum of 100 impressions. Average position over a handful of impressions is noise. The number is a starting floor for a four-week window on a small site; raise it for larger sites until the list becomes something you can work through.
The pages column. When it is greater than 1, several of your URLs appeared for the same query. Sometimes that is harmless, a product page and its category page. Sometimes it is keyword cannibalization, and improving either page won't help until you decide which one should own the query.
What the numbers do not tell you
A few caveats before acting on the list:
-
Average position is a blend. It averages the topmost position of your result across all impressions. An 11.4 can mean steady 11s, or 8 on desktop and 15 on mobile. Add
deviceto the dimensions if a candidate looks odd. - Rare queries are missing. Search Console leaves out anonymized queries, so query-level rows will not add up to the property totals. That is fine for finding candidates and wrong for estimating total traffic.
- CTR depends on the result page. Moving from 11 to 7 does not guarantee clicks if the results are full of ads and SERP features. The glossary entry on organic click-through rate covers what drives it.
Turning candidates into work
Each row deserves one of three decisions: improve the lead page for that query, consolidate competing pages, or drop it because the intent does not match what you offer. Write the decision next to the query, for example in a CSV export of the table, so next month's run shows whether it worked.
For that follow-up, Search Console's blended average is a weak instrument. A rank tracker that checks one keyword from a fixed location and device every day shows movement much more clearly. If you would rather not maintain the script yourself, the Search Console integration connects a property read-only to the Site Audit of the same domain. The Google Search Console glossary entry explains the reports this data comes from.
Top comments (0)