seo-automation
Blog

Content Decay Automation With the Search Console API

November 8, 2026

Content decay automation is a scheduled job that pulls page-level clicks, impressions and position from the Search Console API. It compares time windows and flags pages that lost ground for reasons other than seasonality. It also finds the likely cause of each drop, so your team can refresh, merge or redirect the right pages in the right order without opening a spreadsheet.

This guide starts with the diagnosis and builds the rest of the pipeline around it. It covers the API query with pagination, the seasonality check, cause detection, a priority score and a decision tree. It then covers refresh guardrails, publishing in a single commit for Next.js, Astro or Hugo, and an honest way to measure the result. Every threshold below is a starting point. Tune it on your own data.

Want refreshes handled for you instead of scripted? Start for $1.

Manual, script or tool: pick your level first

There are three ways to do this work. Most teams move from the first to the second or third once they run more than one site.

Approach What it looks like Where it breaks
Manual Export the Pages report every month and compare two date ranges in a sheet Months get missed, you handle one site at a time, nobody checks seasonality
Script A Python or Node job on cron or GitHub Actions, with results in Slack or a sheet You still write and publish every refresh yourself
Tool Detection, rewrite, publishing and resubmission run as one process You need clear rules on what a human still reviews

To size the manual version, time your team on one full refresh. Count pulling the data, reading the current top results, rewriting, checking links and publishing. Then multiply that by every decaying page on every site you manage. If a tool turns out to be the better choice, compare the cost of a per-site SEO automation plan with automatic refreshes with the hours your team spends now.

The rest of this guide covers the script route. It works for one site or for forty.

What content decay is (and what it is not)

Content decay is a lasting drop in a page's organic clicks on queries the page used to win. Impressions and average position usually get worse too. The page did not break. Something around it changed. If you publish a lot, decay is a backlog you manage, not a rare event.

Three patterns look like decay but are not:

  • Seasonality. A "tax deadline" page falls every May. Compare against the same window last year before you flag anything.
  • Tracking or site issues. A noindex tag that slipped in, a broken canonical, a migration. In these cases all pages drop together, not one by one.
  • A falling CTR with a stable position. This is a title and snippet problem. Fix it with a rewritten title, not a full refresh.

There is no official threshold for a "significant" drop. One workable starting point: flag a page when its clicks fell by roughly a quarter or more against the previous window and against the same period last year. Treat that number as a dial, not a rule. Run it for a few cycles and look at what it flags. If the list is full of noise, raise it. If pages you know are sliding never show up, lower it.

How to detect content decay automatically with the Search Console API

The Search Analytics query method returns clicks, impressions, CTR and position by page, by query, or by both. Three facts about it shape the detection job:

  1. Data lag. Data is usually available after 2 to 3 days. End your current window 3 days before today. If you don't, the last days will look like a drop.
  2. Row limits. Each request returns at most 25,000 rows. Page through the results with startRow in steps of 25,000 until a request comes back empty.
  3. Daily cap. Search Console exposes up to 50,000 rows per day per search type, sorted by clicks. On large sites, long-tail page and query pairs get cut. Queries by page only are far more likely to come back complete.

Here is the fetch, with pagination. It uses a service account that has access to the property and reads its key from service-account.json:

from googleapiclient.discovery import build
from google.oauth2 import service_account

creds = service_account.Credentials.from_service_account_file(
    "service-account.json",
    scopes=["https://www.googleapis.com/auth/webmasters.readonly"])
gsc = build("searchconsole", "v1", credentials=creds)

def pages(site, start, end):
    """Return {url: row} for one window, all rows, paginated."""
    out, start_row = {}, 0
    while True:
        body = {"startDate": start, "endDate": end,
                "dimensions": ["page"], "type": "web",
                "rowLimit": 25000, "startRow": start_row}
        rows = gsc.searchanalytics().query(
            siteUrl=site, body=body).execute().get("rows", [])
        if not rows:
            return out
        for r in rows:
            out[r["keys"][0]] = r
        start_row += 25000

The three windows to compare

Run pages() three times:

  • Current: the last 28 days, ending 3 days ago.
  • Previous: the 28 days before that.
  • Last year: the same 28 days one year earlier, if the property has that much history.

A page is a decay candidate when it drops against the previous window and against last year. If it drops against the previous window but matches last year, it is seasonal. Log it and move on.

Use 28-day windows, not 7-day ones. One holiday week or one bad crawl day moves a 7-day window a lot and barely moves a 28-day one.

Filters that remove noise

Before you score anything, remove:

  • pages under a minimum baseline, for example fewer than 30 clicks in the previous window, because small numbers swing for no reason;
  • pages published or refreshed in the last 30 days, because they have not settled yet;
  • URLs outside the blog or docs folders you actually refresh;
  • pages where the whole click drop comes from CTR while position held. Send those to a title rewrite queue instead.

Diagnose the cause before you touch the page

Most decay reports skip this step, and it decides everything that comes after it. The same drop can have five different causes, and each one needs a different fix. Automate the evidence gathering. Leave the verdict to a person until your rules are proven.

For each flagged page, the job runs a second query with dimensions: ["page", "query"], filtered on that URL, for both windows. Then it checks:

Cause What the data shows Usual fix
A competitor overtook you Same queries, same impressions, your position fell Refresh: add what the new top results cover and you do not
Content got dated Queries with a year, version or price, and the page still shows old values Refresh facts, examples and screenshots
Search intent changed Impressions held, clicks fell, and the top results are now another format (tool, list, video) Rework the format, or accept the loss
Algorithm update Many pages fell in the same week Check the Google Search Status Dashboard before editing anything
Cannibalization Another URL on your site gained impressions on the same queries Merge the two pages and redirect the weaker one

The cannibalization check is easy to script, and teams often skip it. Group both windows by query and note which of your URLs gets the impressions for each query. If the URL changed between windows, the traffic was split between two pages. That is not decay.

How to find decaying content in Google Search Console and rank it

A flat list of 200 "decaying" URLs is not useful on its own. The job has to say which page to fix first. This score works as a starting point:

def priority(cur, prev, lead_pages):
    url = cur["keys"][0]          # rows from pages() only carry "keys"
    if prev["clicks"] == 0:
        return 0
    lost = prev["clicks"] - cur["clicks"]
    if lost <= 0:
        return 0
    drop = lost / prev["clicks"]
    pos = cur["position"]
    # Working hypothesis: positions 3 to 20 are the most recoverable
    pos_weight = 1.5 if 3 <= pos <= 20 else 0.7
    # Pages that brought leads matter more than pages that brought traffic
    biz_weight = 3.0 if url in lead_pages else 1.0
    return round(lost * drop * pos_weight * biz_weight, 1)

# cur_win, prev_win = pages(site, ...), pages(site, ...)
# scores = {u: priority(cur_win[u], prev_win[u], lead_pages)
#           for u in cur_win if u in prev_win}

Why each factor is there:

  • Lost clicks (the absolute number) puts a page that went from 2,000 to 1,500 above one that went from 20 to 10.
  • Drop ratio favors pages that are clearly sliding over pages that wobble at the edge.
  • Position band. The 3 to 20 range is a working hypothesis to test. A page at 3 to 10 still gets most of its clicks, so protecting it is urgent. A page at 11 to 20 is close enough to page 1 that a good edit can plausibly move it. Pages far below that usually need more than an edit. After a few cycles, compare your refresh verdicts by starting position and move the band to match your data.
  • Business weight. Join your CRM or form data on the landing page. A page that brings demo requests beats a page that only brings readers. If you have no lead data, use a manual list of money pages.

Send the top 10 to Slack, a sheet or your issue tracker every Monday. Decay is slow, and a daily alert only teaches people to ignore it.

Running this pipeline on one site is a weekend project. Keeping it running across ten sites, with every refresh written, reviewed and published, is a full job. SEO automation handles the refresh side for you, and the pricing page shows what the $49 per site plan covers.

Refresh, merge, redirect or delete: a decision tree

Once the cause is known, every flagged page gets exactly one action. Write the rules down so the script can propose an action and a human only has to confirm it.

  1. Is the drop seasonal or site-wide? Leave the page alone. Fix the site issue or wait for the season.
  2. Is another page on your site taking its queries? Merge the content into the stronger URL, 301 the weaker one, and update internal links.
  3. Does the page still match what the top results answer? Refresh it at the same URL.
  4. Has the intent moved to a format you can build? Rework the page into that format at the same URL.
  5. Has the intent moved to something you do not offer, and does the page bring no leads? If it has backlinks, redirect it to the closest relevant page. If not, remove it and return a 404 or 410.
  6. Is the topic dead (a discontinued product, a finished event)? Redirect it if it has links or traffic. Delete it if it has neither.

Teams use deletion least of all. But a blog full of pages nobody searches for anymore spreads your internal links thin for no benefit.

Automated refresh with guardrails

Rewriting can be automated. But publishing a rewrite without checks is how you lose a page that was only slightly down. Enforce these guardrails in code before any refreshed page is accepted:

  • Same URL, same slug. A refresh never changes the address. A new address throws away the history you are trying to recover.
  • Main query in the title, H1 and first paragraph. Take it from the page's own Search Console queries, not from a guess.
  • Every fact still sourced. New numbers need a link. Old numbers get checked again or removed.
  • Links checked. Every outbound link returns 200, and internal links point to pages that still exist.
  • Structure kept unless the intent changed. Do not drop sections that rank for long-tail queries. Check the page and query list before you cut anything.
  • Dates updated honestly. Change dateModified only when the content really changed. In JSON-LD, use a full ISO 8601 date and time with a timezone, for example 2026-11-08T09:00:00+00:00, not a bare date.
  • A diff for review. The reviewer sees what changed, not a new wall of text.

What to automate and what stays human

Automate Keep a human on it
Weekly detection, scoring and alerts Final call on merges, redirects and deletions
Pulling queries, competitors' headings and missing subtopics Claims about your own product, pricing and customers
First draft of the refresh, with sources Reading the diff before it goes live on money pages
Link checks, schema dates, sitemap and resubmission Deciding that a page should change format
Measurement after the refresh Judging whether a "worse" result is seasonal or real

Google's spam policies target scaled content abuse: large numbers of pages made mainly to manipulate rankings, with little value for readers. A refresh pipeline that improves real pages, keeps facts sourced and has a human approve sensitive changes is the opposite of that. If you skip the review step on the pages that make you money, you take a risk for no gain.

Publish in one commit, then tell the search engines

If your blog is Markdown or JSON in a Git repo (Next.js, Astro, Hugo, or a headless CMS that syncs to Git), push all of the week's refreshes in one commit. One commit means one build and one deploy instead of one per page. On platforms that bill by build minute, that adds up fast.

Here is a minimal GitHub Actions schedule for the detection job. The service account key is stored in a repository secret, and the workflow writes it to service-account.json, the file the script reads:

name: content-decay
on:
  schedule:
    - cron: "0 6 * * 1"   # Mondays, 06:00 UTC
  workflow_dispatch:
jobs:
  detect:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: "3.12" }
      - run: pip install google-api-python-client google-auth
      - name: Write service account key
        run: echo "$GSC_KEY" > service-account.json
        env:
          GSC_KEY: ${{ secrets.GSC_SERVICE_ACCOUNT }}
      - run: python scripts/decay.py --site "sc-domain:example.com"

Add service-account.json to .gitignore so the key is never committed.

After the deploy:

  1. Check that the sitemap's lastmod changed for the refreshed URLs only. If you bump every URL, Google learns to ignore the field.
  2. Resubmit the sitemap with the Search Console API sitemaps.submit method. For this call, the service account needs full permission on the property, and the script needs the webmasters scope instead of the read-only one.
  3. Ping IndexNow with the changed URLs so Bing and the other participating engines crawl them again. Google does not take part in IndexNow, so step 2 still matters.

Measure the refresh at 28 days, not 3

Many teams check a refreshed page two days later, see nothing, and decide refreshes do not work. Keep the 2 to 3 day data lag in mind, then add the time Google needs to crawl the page again and re-evaluate it.

A measurement rule that holds up:

  • After window: 28 days, starting 3 days after the refresh went live.
  • Before window: the 28 days just before the refresh. For a seasonal topic, use the same dates last year instead.
  • Compare per day: clicks per day, impressions per day, CTR and average position. Daily figures stay fair even when the windows differ by a day.
  • Verdict: better, same or worse, with the reason in one line. Store it next to the page.

Two cautions. First, one page proves nothing. Judge your refresh rules on the last 10 or 20 refreshes, not on a single page. Second, a page can gain impressions and lose average position at the same time, because it now ranks for more queries and some of them rank lower. Read the query list before you call it a failure.

After a few months, those stored verdicts become your best input. They show which causes respond to a refresh, which page types come back, and which pages you should redirect next time instead of rewriting. They are also how you check the thresholds and the position band you started with.

How SEO Automation handles this

SEO Automation picks up articles sitting on page 2 and refreshes them at the same address. It also rewrites titles on pages with a low click-through rate. Every refresh goes through the same fact and link checks as a new article. Once a refresh is published, the sitemap is resubmitted in Search Console and IndexNow is notified. You keep the decision on anything that should be merged, redirected or deleted. You should still read refreshes of your money pages before you rely on them.

Frequently asked questions

How do you detect content decay automatically?

Schedule a job that pulls page-level clicks, impressions and position from the Search Console API for three windows: the last 28 days ending 3 days ago, the 28 days before that, and the same period last year. Flag pages that dropped against both comparison windows and filter out small pages and recent edits. Then rank the rest by lost clicks, position and business value. Running it once a week is enough.

What drop in traffic means a page is decaying?

There is no official threshold. A practical starting point is a click drop of roughly a quarter or more over a 28-day window. Require the drop against last year too, so seasonality does not trigger it. After a few cycles, look at what the rule flags and raise or lower it to match how your pages behave.

How often should you refresh old content?

Refresh when the data shows a page is sliding, not on a fixed calendar. A weekly detection job with a short top 10 list keeps the work steady. Leave a refreshed page alone for at least 28 days before you judge it or edit it again.

Should you refresh, merge, redirect or delete a decaying page?

Refresh it when the page still matches what searchers want. Merge it when another page on your site competes for the same queries. Redirect it when the topic has moved on but the page has links or traffic. Delete it when it has neither. Have a human confirm merges, redirects and deletions, because they are hard to undo cleanly.

Start for $1 and get your SEO plan in a few minutes.