Most content sites have pages sitting at positions 8 to 20 for queries they could win. Google already thinks they're relevant. What they often lack is links from the other pages on the same site, because nobody went back to add them after publishing.
Search Console tells you exactly which page ranks for which query. A coding agent can read that, read your Markdown files, and propose links where your existing text already uses the right phrase. If the site lives in a Git repo (Astro, Eleventy, Hugo, anything with Markdown content), you get a free safety net: the agent works in a branch, you review a diff, and the pull request is your changelog.
Here's the setup, a script to pull the data, and the prompt.
1. Get page and query data together
This is where most versions of this workflow quietly break. The Performance report export in Search Console gives you a Queries table and a Pages table, but not which query belongs to which page. Each export is also capped at 1,000 rows. To say "page X ranks 12th for query Y", you need both dimensions in one row, and the Search Console API gives you that.
One-time setup:
- In Google Cloud, create a project, enable the Search Console API, create a service account and download its JSON key.
- In Search Console, go to Settings > Users and permissions and add the service account's email as a user.
- Put the key outside the repo, or add it to
.gitignore. Never commit it.
npm i -D googleapis
echo ".gsc/" >> .gitignore
echo "gsc-key.json" >> .gitignore
Then add scripts/gsc-export.mjs:
// scripts/gsc-export.mjs
import { google } from 'googleapis';
import { mkdirSync, writeFileSync } from 'node:fs';
const SITE = process.env.GSC_SITE; // 'sc-domain:example.com' or 'https://example.com/'
const day = (n) => new Date(Date.now() - n * 864e5).toISOString().slice(0, 10);
const auth = new google.auth.GoogleAuth({
keyFile: process.env.GSC_KEY_FILE,
scopes: ['https://www.googleapis.com/auth/webmasters.readonly'],
});
const gsc = google.searchconsole({ version: 'v1', auth });
const rows = [];
for (let startRow = 0; ; startRow += 25000) {
const { data } = await gsc.searchanalytics.query({
siteUrl: SITE,
requestBody: {
startDate: day(93),
endDate: day(3), // skip the most recent days, which can still change
dimensions: ['page', 'query'],
type: 'web',
rowLimit: 25000,
startRow,
},
});
rows.push(...(data.rows ?? []));
if (!data.rows || data.rows.length < 25000) break;
}
mkdirSync('.gsc', { recursive: true });
writeFileSync('.gsc/page-query.json', JSON.stringify(rows.map((r) => ({
page: r.keys[0],
query: r.keys[1],
clicks: r.clicks,
impressions: r.impressions,
position: Math.round(r.position * 10) / 10,
})), null, 2));
console.log(`Saved ${rows.length} rows`);
Run it:
GSC_SITE="sc-domain:example.com" GSC_KEY_FILE="$HOME/keys/gsc-key.json" node scripts/gsc-export.mjs
rowLimit maxes out at 25,000 per request, so the loop pages through with startRow. Don't expect every query: the API returns top rows, and rare queries are left out for privacy. On a small site that can be a big share of them. That's fine, because the queries you want for this job are the ones with real impressions.
If your agent already has a Search Console MCP server connected, you can skip the script. A JSON file in the repo is easier to rerun and diff, though.
2. Put a number on "decent impressions"
The original prompt asks for queries with "decent impressions", which the model will interpret however it likes. Give it a number. For a small local business site over three months, 50 impressions per page and query pair is a reasonable floor. A blog with real traffic might use 200 or more. Pick the number that leaves you with a list you can actually review, somewhere around 20 to 60 target pairs.
Also fix the position range. The original says to take each article's queries ranking 1 to 10 as anchors, then prioritize targets ranking 4 to 15. Those two rules fight each other: a page sitting at 13 has no query in the top 10, so it never becomes a target. Use one rule. Target pairs where the page ranks between 4 and 20 with impressions above your floor. Pages ranking 1 to 3 don't need the help.
3. Run the agent on a branch
git switch -c internal-links-$(date +%F)
Then give the agent the prompt below. Tell it where content lives (for Astro, usually src/content/blog/ or wherever your content collections point) and how URLs map to files. Search Console reports full URLs like https://example.com/blog/tankless-vs-tank/, and the agent has to turn that into src/content/blog/tankless-vs-tank.md. If your astro.config.mjs sets trailingSlash, the new links must match it, or every link adds a redirect hop.
4. Review the table before any edit
The agent writes the plan to .gsc/link-plan.md and stops. Go through every row. Reject a row if:
- the anchor doesn't make sense read on its own
- the source page has nothing to do with the target
- it's a self-link, or the target is already linked from that page
- the same exact anchor shows up on more than two or three source pages
That last one matters more than people think. Google's link best practices ask for anchor text that is descriptive and relevant, and warn that stuffing keywords into anchors breaks its spam policies. If twenty posts all link to one page with the identical exact-match query, it looks engineered, because it is. Mix it up: partial matches, the page's topic in your own words, the query with a city name added. The prompt below asks for this, but check anyway.
5. Apply the edits as a pull request
After you approve, the agent edits the Markdown, commits, and you open a PR:
npm run build
npx linkinator ./dist --recurse --skip "^https?://(?!example\.com)"
gh pr create --title "Internal links from GSC, $(date +%F)" --body-file .gsc/link-plan.md
The build catches broken MDX. Linkinator crawls the built site and fails on any link that 404s. The PR body is the approved table, and the diff shows every changed sentence. No separate changelog to keep, and reverting a bad batch is one git revert.
Read the diff anyway. Look for rewritten sentences (the agent was told not to, and sometimes does it), links inside headings or frontmatter, and links dropped into code blocks.
6. Measure with matching date ranges
Write down the merge date. The new links only count once Google recrawls the source pages, the ones you edited, not the targets. For a handful of important source pages you can use URL Inspection > Request indexing to speed that up.
A few days is too soon to judge anything. Average position bounces around week to week on its own. Wait at least four weeks after merge, then in the Performance report use Date > Compare with two custom ranges of the same length: the 28 days before the merge and the 28 days starting a week after it. Filter by each target page and check its target query. If you'd rather script it, run the export twice with those dates and have the agent diff the two files for the target pairs only.
This is also why running it every week on overlapping pages makes the results hard to read. Do a batch, wait, measure, then do the next batch on different targets.
The prompt
You are working in a Git repo for [site name], built with [Astro / Eleventy / Hugo].
Content files: [src/content/blog/**/*.md and *.mdx]. URLs map to files like this: [https://example.com/blog/<slug>/ -> src/content/blog/<slug>.md]. Trailing slash setting: [always / never].
Search Console data (page, query, clicks, impressions, position, last 3 months): .gsc/page-query.json
Step 1, pick targets. A target is a page + query pair with position between 4 and 20 and at least [50] impressions. Skip pages ranking 1-3 for that query. Ignore URLs you can't map to a file.
Step 2, find link spots. For each target, search the other content files for places where the existing text already says the query, a close variant or the same idea in other words. Only use sentences in body paragraphs. Never use headings, frontmatter, the first paragraph, image alt text, code blocks, MDX components or text that is already a link.
Rules:
- At most [3] new links per source file, and each target linked once per source file.
- No self-links. Skip a source file that already links to the target.
- The source page must be on a related topic. If it isn't, don't link.
- Do not rewrite or add sentences to fit an anchor. Wrap existing words only.
- Vary anchors. Don't use the identical exact-match anchor for the same target on more than [2] source files; use partial matches and descriptive wording instead.
- Links are root-relative Markdown links, e.g. [anchor](/blog/slug/), matching the trailing slash setting.
- Prefer targets with the most impressions first.
Step 3, plan only. Write .gsc/link-plan.md as a table: source file | sentence (with the anchor in brackets) | target URL | target query | position | impressions. Below it, list targets you found no natural spot for. Do not edit any content file yet. Stop and wait for my approval.
Step 4, after I reply "approved" (or approve specific rows), apply only the approved rows, commit with the message "Add internal links from GSC data", and show me git diff --stat.
To check the output, pick five rows from the plan and open the source file yourself: the bracketed words should already be in the sentence, unchanged, and the sentence should still make sense if you read only the anchor.
Where to start
Run the export, filter to positions 11 to 20 with your impression floor, and do one batch of ten targets. Merge it, write the date in the PR, and leave those pages alone until you've done the before/after comparison.
Based on a post by andrei saioc (@asaio87) on X: https://x.com/asaio87/status/2115449821100232775, with corrections and extra steps.
Top comments (0)