I let an AI pipeline write my blog. Google demoted the whole site.
A first-person autopsy of a self-inflicted Helpful-Content demotion — the junk I shipped, the code that shipped it, and the reversible way I dug out.
By Andrew Pyle
I built a Django pipeline that could write and publish blog posts on its own. It worked exactly as designed — which was the problem. Over a few months it filled my personal site with a few dozen templated articles like "quantum computing tips for athletes" and "where quantum outperforms classical in sports." They were grammatical. They were indexed. And they were quietly poisoning the entire domain.
This is the honest version of what happened next: how I noticed, how I diagnosed it, and the specific, reversible sequence I used to dig the site out. If you run any kind of programmatic-content pipeline, this is the failure mode nobody demos.
01The fall
The traffic didn't dip. It fell off a shelf.
I wasn't watching closely, which is its own lesson. When I finally pulled the Search Console numbers, the shape wasn't a slow decline — it was a cliff. Average position slid from the low-20s into the low-30s, and impressions collapsed by nearly an order of magnitude, month over month.
| Metric | Change |
|---|---|
| Avg position | 21.6 → 31.4 |
| Impressions / mo | 22.5k → 2.5k |
| Worst page: impressions / clicks | 3,047 / 0 |
That last column is the tell. One page — where-quantum-outperforms-classical-in-sports — had earned 3,047 impressions and zero clicks, ever. Not a low click-through rate. Zero. It ranked well enough to be shown thousands of times, and not one human ever chose it. Multiply that by thirty or forty near-identical pages and you have a machine broadcasting a single signal to Google: this site publishes things nobody wants.
02Site-wide, not page
This was a site-wide demotion, not a page problem.
The instinct is to treat it as a per-page issue — fix the titles, improve the snippets. That instinct is wrong here, and chasing it would have wasted months. The distribution told the real story: only a couple of dozen queries ranked on page one; the mass of the site sat at positions 31–50. Click-through was near zero because average position was ~35, not because the snippets were weak.
The pages weren't underperforming individually. They were dragging a low-authority personal domain down as a set — a classic Helpful-Content demotion, triggered by scaled, no-intent content.
Google's Helpful Content system doesn't grade essays one at a time. It forms a view of the site. A cluster of templated, never-clicked pages is exactly the input that view is built to punish — and the punishment lands on your good pages too. On a small domain that is lethal: a dozen pages worth keeping, held under by forty that weren't.
03The bad defaults
Every default was set to "publish more."
The uncomfortable part of the autopsy was reading my own pipeline. The content_expansion app had four defaults that, combined, made junk the path of least resistance:
# content_expansion — the four failure defaults
INDUSTRIES = [...41 hardcoded verticals...] # no demand gate
published = True # live on generation, no review
prompt += "cite Gartner/McKinsey stats" # fabricated authority
dedup_on = "slug" # apostrophe = "new" articleRead those together. A hardcoded list of 41 industries with no check for whether anyone searches for the intersection. Posts that went live the moment they were generated, with no human in the loop. A prompt that told the model to invent citations to firms it had never read. And deduplication so naive that python-s-asyncio and pythons-asyncio — the same article, one apostrophe apart — both counted as original.
None of these is exotic. Every one is the kind of default you'd ship to "get to scale," and every one is a small lie that Google eventually adds up. The missing demand gate is the one I regret most: forty pages targeted intersections — quantum computing and youth sports — that no human has ever typed into a search box. The pipeline never asked whether the query existed, only whether it could produce a paragraph.
04The sequence
Prune first. Prove nothing regrows. Then improve.
The sequence mattered more than any single move. You cannot out-write a demotion while the thing that caused it is still live and still regenerating. So the order was deliberate:
Prune — reversibly
I chose noindex over deletion for the worst ~15 pages, and 301 redirects to consolidate the rest. Reversible on purpose: a demotion recovery is a hypothesis, and I wanted an undo button if a pruned page turned out to matter. A new DB column, a migration, and an idempotent management command — no page hard-deleted.
Close the tap
Flip the generators to published=False by default so nothing goes live without review. Delete the fabricated-statistics prompt outright. The prune is worthless if the pipeline refills the hole next week.
Deepen what was real
A handful of pages were thin but on a real subject. I rewrote four of them by hand — replacing templated filler with genuine code and worked examples. The honest trade: rendered word count dropped ~35%, but each gained a dozen-plus real code blocks. Fewer words, more substance.
Consolidate the cannibals
Whole clusters were competing with themselves — a dozen "AI tools" listicles, thirty-plus overlapping Django posts. I collapsed them with 301s into single strong survivors — /blog/best-free-ai alone had 3,393 impressions over ninety days — gated on Search Console proof the redirected pages had no traffic to lose. The sitemap shrank from 913 URLs to 875. A smaller, truer map.
05Reversible in code
The prune is two columns and an undo flag.
"Reversible" is a nice word until you have to build it. The entire prune lives in one migration — blog migration 0038 — that adds two fields to the post model. No deletes, no archive table, no separate store to keep in sync.
# blog/migrations/0038 — the reversible prune, as two columns
noindex = BooleanField(default=False) # meta robots noindex,follow + drop from sitemap
redirect_to = SlugField(default="") # if set, /blog/<slug>/ 301s to the survivor
# apply_seo_consolidation is idempotent AND has an explicit undo:
python manage.py apply_seo_consolidation # dry-run: preview only
python manage.py apply_seo_consolidation --apply # set redirect_to on the satellites
python manage.py apply_seo_consolidation --revert # clear every redirect it setThe command runs as a dry run by default and prints exactly what it would change before touching anything. --apply writes the flags; --revert clears the ones it set. Because the state lives in ordinary columns, undoing a bad pruning decision is one command — I unredirected two pages after Search Console showed they had distinct intent I'd underrated.
The subtlety is where those flags take effect. Setting redirect_to is only a database write; the 301 doesn't fire until a deploy runs generate_nginx_seo_rules, and the noindexed pages don't leave search until generate_unified_sitemap rebuilds the sitemap without them. And dev and prod run separate databases — set the flags against the wrong one and you'll swear it worked while production keeps serving the junk. The write is instant; the effect is a deploy away.
06The serving trap
The serving layer will lie to you.
The strategy was the easy part. The mechanics ambushed me twice.
First: my blog is served by a React SPA fed by an API, not by server-rendered Django templates. So every "SEO fix" I made in a Django view or the contrib sitemap was inert in production — the real levers were an nginx X-Robots-Tag rule and a static, build-time sitemap. If you don't know exactly which layer a crawler actually sees, you will spend days fixing a file Google never reads.
Second: a shared prerender service caches every rendered URL for 24 hours. After I applied a content change, crawlers on the bare canonical URL kept getting the pre-change render — and Cloudflare happily reported the response as fresh. The staleness was one layer deeper than the CDN. The fix was a single explicit purge:
curl -X POST "http://prerender/clear-cache?domain=andrewjpyle.com"
# verify with a Googlebot fetch of the BARE url —
# a ?cb= param is a different cache key and always looks fresh07The recovery gate
What the recovery gate actually measures.
I built a small measurement gate instead of declaring victory, because a green dashboard is a claim, not a fact until a real request confirms it. The dig-out is finished when specific pages behave the way the theory says.
So I track named pages through named stages, not a blended number. A URL is submitted, then crawled, then indexed, then ranked — four states, and a recovery can stall at any one. The worst offender dropping out of the index is an early win at the third stage; it says nothing yet about the fourth. Blend them into one number and you learn nothing about which lever moves.
The verification has to hit reality, not a cache: I fetch the bare canonical URL as Googlebot sees it, with no cache-busting parameter. The gate passes only when the URL a crawler requests returns the state I intended.
08No hockey stick
I'm not going to tell you it worked yet.
Here's where an SEO case study usually shows the hockey-stick recovery chart. I don't have one, and I'm not going to fake it. A Helpful-Content recovery runs on Google's clock, not mine — typically months — and the honest read at the time of writing is: the worst offender has dropped out of the rankings (an early de-index signal), the survivors are holding, and the real recovery gate hasn't been measured yet.
That's the actual state of a demotion dig-out: you do the correct, reversible work, you close the tap, and then you wait and watch specific pages — without pretending the waiting is over.
The larger lesson isn't "don't use AI to write." It's that a content pipeline needs the same discipline as any other system that writes to the outside world: a demand gate before it generates, a human before it publishes, real dedup, no invented facts, and an undo button for everything. I had built a machine that was very good at doing the wrong thing quickly. The fix wasn't turning the machine off — it was giving it the guardrails I should have built first.