Automate Title Tag & Meta Description Rewrites: A CTR-Driven GSC Pipeline (Python + LLM)
Your content team writes a great title, ships the page, and moves on. Eighteen months later that title is still sitting in the source — even though the query landscape underneath it has shifted, a competitor has out-promised you in the SERP snippet, and your click-through rate has quietly bled out. Multiply that across a few thousand URLs and you have one of the largest pools of recoverable traffic on most sites: pages that already rank but get skipped.
Manually auditing title tags and meta descriptions at that scale is a non-starter. But the fix is unusually well-suited to automation, because Google Search Console hands you the exact signal you need — impressions, clicks, position, and CTR per page and per query — and a language model is genuinely good at rewriting a snippet to be more clickable. This post walks through a CTR-driven rewrite pipeline that finds the pages worth fixing, drafts new metadata with an LLM, runs it through hard QA gates, and queues the winners for human approval. Working Python included.
Why CTR decay is different from content decay
We’ve covered the automated content decay detector before — a pipeline that flags pages losing traffic so you can refresh the body copy. CTR decay is its quieter cousin. The page hasn’t lost rankings; it’s holding position 4, getting plenty of impressions, but the snippet no longer earns the click it used to. Refreshing the article body won’t help. The lever here is the SERP snippet itself: the title tag and meta description.
The tell is a page where position is stable or improving but CTR is below the curve for its position. Position 3 on a commercial query should pull roughly 10–13% CTR depending on vertical and SERP features; if you’re sitting at 4%, that gap is the prize. The whole pipeline is built to surface those pages and nothing else.
Step 1 — Pull the underperformers from the GSC API
Start with a Search Console query that returns page + query rows for the last 28 days. You’ll need a service account with access to the property; if you’re choosing between data sources, our GSC API vs SEMrush vs Ahrefs comparison covers the trade-offs — for first-party CTR, GSC is the only honest source.
from google.oauth2 import service_account
from googleapiclient.discovery import build
import pandas as pd
SITE = "https://example.com/"
creds = service_account.Credentials.from_service_account_file(
"sa.json", scopes=["https://www.googleapis.com/auth/webmasters.readonly"]
)
gsc = build("searchconsole", "v1", credentials=creds)
def query(dimensions, start, end, row_limit=25000):
rows, start_row = [], 0
while True:
resp = gsc.searchanalytics().query(siteUrl=SITE, body={
"startDate": start, "endDate": end,
"dimensions": dimensions,
"rowLimit": row_limit, "startRow": start_row,
}).execute()
batch = resp.get("rows", [])
rows += batch
if len(batch) < row_limit:
break
start_row += row_limit
return rows
pages = query(["page"], "2026-05-18", "2026-06-14")
df = pd.DataFrame([{
"url": r["keys"][0],
"clicks": r["clicks"], "impressions": r["impressions"],
"ctr": r["ctr"], "position": r["position"],
} for r in pages])
Step 2 — Score the opportunity, don't just sort by CTR
Sorting by raw CTR is a trap: it surfaces low-impression long-tail noise. What you want is expected clicks left on the table. Compare each page's actual CTR against a benchmark CTR for its position, then multiply the gap by impressions to get a recoverable-clicks estimate.
# Rough position->CTR benchmark curve (tune to your vertical from your own data)
BENCH = {1:0.28,2:0.15,3:0.11,4:0.08,5:0.06,6:0.05,7:0.04,8:0.035,9:0.03,10:0.025}
def bench_ctr(pos):
p = max(1, min(10, round(pos)))
return BENCH[p]
df["bench_ctr"] = df["position"].apply(bench_ctr)
df["ctr_gap"] = df["bench_ctr"] - df["ctr"]
df["recoverable_clicks"] = (df["ctr_gap"] * df["impressions"]).clip(lower=0)
candidates = (df[(df["impressions"] >= 200) & (df["position"] <= 12)
& (df["ctr_gap"] > 0.02)]
.sort_values("recoverable_clicks", ascending=False)
.head(50))
The filters matter more than the model. A floor of 200 impressions kills noise; position <= 12 keeps you on pages that actually rank; a 2-point CTR gap ensures there's real upside. Fifty candidates a week is a sane batch — enough to move the needle, small enough to QA.
Step 3 — Give the LLM the right context
The quality of the rewrite is almost entirely a function of context. Don't just hand the model the old title. For each candidate, fetch the page's current <title> and meta description, the H1, and — this is the important part — the top 3 actual queries that page ranks for from GSC. The model should optimize for the queries people really use, not the ones you wish they used.
import anthropic, json
client = anthropic.Anthropic()
PROMPT = """You are an SEO copywriter. Rewrite the title tag and meta description for this page.
Page H1: {h1}
Current title: {title}
Current meta: {meta}
Top queries (impressions): {queries}
Hard rules:
- Title: 50-60 characters, include the primary query naturally, no clickbait, no brand stuffing.
- Meta description: 140-155 characters, active voice, one concrete benefit, soft CTA.
- Do not invent features, prices, dates, or superlatives you can't verify from the H1.
Return strict JSON: {{"title": "...", "meta": "..."}}"""
def rewrite(row, h1, title, meta, queries):
msg = client.messages.create(
model="claude-sonnet-4-6", max_tokens=400,
messages=[{"role":"user","content":PROMPT.format(
h1=h1, title=title, meta=meta,
queries=", ".join(queries))}],
)
return json.loads(msg.content[0].text)
Two things make or break this prompt. First, the explicit character ranges — models drift long, and a 71-character title gets truncated in the SERP. Second, the anti-hallucination rule: without it, LLMs cheerfully add "#1 Rated" and "2026's Best," which is both false and a Google quality risk.
Step 4 — QA gates the model never sees around
Never push an LLM's output straight to production. Run every rewrite through deterministic checks and discard anything that fails. This is the single most important part of the pipeline — it's what separates a tool you trust from one that quietly poisons your SERPs.
import re
def qa(out, banned_brandwords, primary_query):
t, m = out["title"], out["meta"]
checks = {
"title_len": 40 <= len(t) <= 62,
"meta_len": 120 <= len(m) <= 158,
"has_query": primary_query.split()[0].lower() in t.lower(),
"no_clickbait": not re.search(r"(?i)\b(best ever|guaranteed|#1|miracle)\b", t+m),
"no_dupe_words": len(set(t.lower().split())) > len(t.split())*0.6,
"no_unverified_year": not re.search(r"\b20\d{2}\b", t+m),
}
return all(checks.values()), checks
Pages that fail QA aren't deleted — they're routed to a "needs human" bucket. Over a few weeks the failure reasons tell you exactly where the prompt needs tightening, which is a feedback loop worth keeping.
Step 5 — Orchestrate it in n8n and keep a human in the loop
The Python above is the engine; n8n is the chassis that runs it on a schedule and routes approvals. A clean weekly workflow looks like this:
Schedule trigger (weekly) → Execute Command / HTTP node runs the GSC pull and scoring → Loop over the top 50 candidates → HTTP node calls the LLM rewrite → Function node applies the QA gates → Filter splits pass/fail → passing rows go to a Google Sheet or Slack approval card with old-vs-new side by side → on approval, a final node pushes the change via your CMS API or opens a pull request against your templates.
The human-in-the-loop step is deliberate. Metadata is high-leverage and public; you want a person clicking "approve" on the first few hundred until you trust the failure rate. If you've already built the GPT-powered content brief generator in n8n, this slots into the same pattern — same approval card, same CMS push node.
What to expect from the numbers
Treat every batch as an experiment, not a fire-and-forget. Stamp the change date, then compare the 28 days before and after for each edited URL, holding position roughly constant so you're measuring snippet impact and not ranking movement. In practice, pages that genuinely had a CTR gap tend to recover a meaningful slice of it; the ones that don't move are telling you the gap was caused by SERP features (an AI Overview, a pack, an ad stack) that no title rewrite can beat. Logging that distinction is how the pipeline gets smarter — it stops proposing rewrites for queries where the snippet was never the bottleneck.
Run this monthly and it compounds. Each cycle clears the current backlog of CTR-decayed pages, and because you're re-pulling fresh GSC data every time, it automatically catches the new ones as queries and competitors shift around you.
Takeaways
Title and meta rewrites are the highest-ROI automation in SEO precisely because the page already ranks — you're converting existing impressions, not chasing new ones. Score by recoverable clicks rather than raw CTR, feed the model the real queries, gate every output through deterministic QA, and keep a human approving until the numbers earn your trust. The whole loop is maybe 200 lines of Python and one n8n workflow.
If you want more build-it-yourself automation playbooks like this one, bookmark the blog — we publish a new working pipeline most mornings. Free tooling that pairs well here: the Search Console API (no cost, first-party data), a self-hosted n8n instance for orchestration, and any LLM API for the rewrite step.
FAQs
Won't Google ignore or rewrite my title tags anyway?
Google rewrites titles roughly a third of the time, usually when your title is keyword-stuffed, far too long, or mismatched with the page. A well-scoped, query-aligned title within the character budget is far more likely to be kept verbatim — which is another reason the QA character gates matter.
How often should I run the pipeline?
Monthly is the sweet spot for most sites. CTR needs a few weeks of post-change data to evaluate honestly, and running weekly on the same pages doesn't give the signal time to settle. Pull fresh GSC data each run so newly-decayed pages enter the queue automatically.
Is it safe to auto-publish the LLM's rewrites?
Not at first. Keep a human approving the side-by-side diffs until your QA pass rate and post-change CTR lift are consistent — typically a few hundred edits. After that you can raise the auto-publish threshold for high-confidence rewrites and keep human review only for edge cases.
What if CTR doesn't improve after the rewrite?
That usually means the snippet was never the bottleneck — an AI Overview, featured snippet, or ad block is intercepting the click. Log those cases and exclude the query pattern from future batches so the pipeline stops proposing rewrites it can't win.
