SEO & Growth No. 429

Content Refresh skill for Claude Code

Fix the pages that are actually decaying, and only those. Claude compares Search Console exports year over year with a bundled script, rules out seasonality and sitewide causes, then reads each page's queries and the live SERP to name why it slipped. Every page gets one action (refresh, rewrite, consolidate, prune, or leave), destructive steps wait for your confirmation, and results are judged against an untouched control group.

  • namecontent-refresh
  • size180 lines · ≈2,550 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Finds pages losing organic traffic and decides, page by page, whether to refresh, rewrite, consolidate with a 301, prune with noindex or 410, or leave alone, using Search Console page and query data, year-over-year comparisons, SERP and intent checks, and cannibalization detection. Use when blog or docs traffic is declining, after a core update, when planning a content audit or refresh calendar, when two pages compete for one query, or when asked which old posts to update, merge, or delete.

Try asking

  • Our blog traffic is down 30% year over year. Find which posts are decaying and tell me what to do with each
  • We have three posts about onboarding checklists competing with each other. Should we merge them and how?
  • Here are two Search Console page exports. Build a refresh plan for the next quarter

Use it when

  • Organic clicks to blog, guide, or docs pages are declining
  • Planning a content audit or a recurring refresh calendar
  • Two or more of your pages split rankings for the same query
  • Deciding which old posts to update, merge, or remove after a core update

Skip it when

  • Pages vanished from the index or the whole site dropped at once — use technical-seo first
  • You are planning brand-new content rather than fixing existing pages — use seo-content-briefs

How to check the result

Live-fetch each changed URL to confirm 200, single-hop 301, or 410 as planned, then compare the refreshed set's click change to an untouched control set at 4 and 8 weeks.

The common mistake it prevents

Bumping the published date or rephrasing paragraphs without adding new information looks like a refresh but gives Google nothing new to reward.

~/.claude/skills/content-refresh/SKILL.md

--- name: content-refresh description: Finds pages losing organic traffic and decides, page by page, whether to refresh, rewrite, consolidate with a 301, prune with noindex or… license: MIT ---

Done well means every page you touch had a real, diagnosed decline (not seasonality, a tracking change, or a SERP layout shift you cannot fix), received the one action that fits the cause, and was measured against a control group afterward. Updating a date is not a refresh; deleting low-traffic pages is not a strategy.

Ground rules

  • Diagnose before editing. Each decaying page gets a stated cause from the table in step 3 before anyone writes a word.
  • Compare year over year first. Month-over-month comparisons mostly measure seasonality.
  • Keep the URL. A refresh never changes the slug; a consolidation always 301s the losers into the winner. Neither ever redirects to the home page.
  • Change dateModified and the visible “Updated” date only for substantive edits (new information, corrected facts, restructured sections). Date-only bumps are a known spam pattern and earn nothing.
  • Deleting, noindexing, or redirecting pages is irreversible in practice (links and rankings decay while you wait to undo). List the URLs and get explicit user confirmation before shipping any prune or consolidation.
  • Low traffic is not low value. Support docs, legal pages, pages with backlinks, and pages that convert stay unless the user decides otherwise.

Workflow

- [ ] 1. Pull page-level data (16 months) and set the comparison windows
- [ ] 2. Flag decayed pages and rule out false alarms
- [ ] 3. Diagnose the cause per page from its queries and the live SERP
- [ ] 4. Choose the action: refresh, rewrite, consolidate, prune, leave
- [ ] 5. Execute in batches of 10-20 pages, confirming destructive actions
- [ ] 6. Request recrawl, annotate the change date
- [ ] 7. Measure against an untouched control group at 4 and 8 weeks
- [ ] 8. Report and schedule the next cycle

1. Pull the data

Search Console keeps 16 months, so export before it rolls off. Sources in order of preference:

Site sizeSourceNotes
< 1,000 URLsPerformance report, Pages tab, Compare mode, export CSVUI caps at 1,000 rows per export
1,000-50,000 URLsSearch Analytics API, dimensions: ["page"] then ["page","query"]25,000 rows per request, paginate with startRow
Larger or recurringBulk data export to BigQueryIncludes anonymized-query share; set it up now, it is not backfilled

Windows: last 3 full months vs the same 3 months a year earlier (primary), plus last 3 months vs the 3 before (secondary, catches recent drops). Add GA4 landing-page conversions for the same windows so the priority score reflects business value, not clicks alone.

2. Flag decay and rule out false alarms

Export one CSV per window (Pages tab: URL, clicks, impressions, CTR, position) and run:

python3 scripts/decay_report.py current.csv baseline.csv --min-baseline-clicks 50 --drop 0.25

It joins on normalized URL, flags pages whose clicks fell by at least the --drop fraction from a baseline above the floor, labels the likely pattern (position loss, CTR loss, demand loss, vanished), sorts by clicks lost, and exits 1 if either file lacks usable columns. Treat its pattern label as a lead, not a verdict.

Before calling a page decayed, rule out:

  • Sitewide shifts: if most pages fell together on the same date, check for a core update, a migration, a tracking or consent change, or a robots/noindex accident (hand that to technical-seo), not page-level content work.
  • Seasonality: the YoY window handles most of it; for new pages without a year of history, check Google Trends for the head query.
  • URL changes: a page that “vanished” may have been redirected; its clicks moved.
  • Reporting changes: Search Console counts AI Overview and AI Mode appearances inside Web search totals, so impressions can rise while clicks fall without any ranking loss.

3. Diagnose the cause

Open the page’s top 10 queries (by baseline clicks) for both windows, then search the top 3 queries in a clean browser session and look at what ranks now.

Signal in the dataLikely causeFix category
Position dropped 3+ places on main queries, competitors now fresher or deeperContent fell behindRefresh
Position dropped, SERP now shows a different format (tools, videos, lists, product pages)Intent shiftedRewrite or retarget
Position stable, impressions stable, CTR downSERP features or an AI Overview absorbing clicksRewrite title and meta, add a quotable answer block; accept partial loss
Impressions down on all queries, position stableDemand fell (fad, product renamed, season)Leave, or retarget to the new terminology
Two of your URLs alternate on the same queriesCannibalizationConsolidate
Clicks lost on facts that changed (prices, versions, laws, screenshots)Outdated informationRefresh
Page lost rankings after a core update along with similar thin pagesHelpfulness or quality judgmentRewrite with first-hand evidence, or prune the class
Page dropped out of the index entirelyTechnical or “Crawled - currently not indexed”Hand to technical-seo first

Cannibalization check: in the page+query export, find queries where two or more of your URLs each get at least 10 percent of the query’s impressions across the window. One URL switching in for another for a week is noise; persistent splitting is cannibalization.

4. Choose the action

ActionUse whenWhat it involves
RefreshIntent unchanged, content outdated or thinner than current top resultsUpdate facts, add missing subtopics, new examples, current screenshots, tighter intro
RewriteIntent shifted, or the page fails a helpfulness readNew outline from today’s SERP (use seo-content-briefs), same URL
ConsolidateTwo or more pages target the same intentMerge the best material into the strongest URL, 301 the rest to it
Prune: 410No traffic, no links, no conversions, no plausible future valueRemove, return 410, drop from sitemap and internal links
Prune: noindexNeeded by users (old changelogs, event recaps) but not search-worthyKeep live, noindex,follow, out of the sitemap
LeaveDecline is demand-driven or within normal variance (under 15 percent)Note it, recheck next cycle

Pick the consolidation winner by, in order: most referring domains, most conversions, most clicks, cleanest URL. Never consolidate pages with different intents just because they share a keyword (“pricing” and “pricing calculator” are usually different jobs).

Priority score per page: clicks lost x conversion rate (or value per visit) x confidence, where confidence is 1.0 for a clear diagnosis and 0.5 for a guess. Work the top 20 first.

5. Execute a refresh

For each refreshed page:

  1. Re-read the top 5 ranking pages for the main query. List what they answer that yours does not, and what yours offers that none do. Keep the latter prominent.
  2. Fix every stale fact, version number, price, screenshot, and broken outbound link.
  3. Add first-hand evidence: your data, test results, photos, worked examples, quotes from practitioners. This is what experience in E-E-A-T looks like on the page.
  4. Put a direct 40-60 word answer to the main query directly under the H1 or the first H2.
  5. Cut sections that no longer serve the intent; shorter often ranks better than padded.
  6. Rewrite the title (under ~60 characters, main term near the front) only if CTR is the problem or the title no longer matches the content.
  7. Add 3-5 internal links from relevant, well-trafficked pages to the refreshed page, with descriptive anchors; fix internal links that pointed at consolidated URLs.
  8. Update the visible date and dateModified in the Article schema together.

For a consolidation, also: map every section of each losing page to a place in the winner (or drop it deliberately), ship the merged page first, then add 301s, then update internal links and the sitemap in the same release.

Gotchas

  • Refreshing many pages on the same day makes measurement impossible. Stagger batches by a week or keep a written change log with dates.
  • Rewriting a page that still ranks top 3 on its main query is how refreshes lose traffic. For pages still winning, change facts and additions only, not structure.
  • Changing H1 and title to a different keyword retargets the page; existing rankings can drop while new ones build.
  • AI-assisted rewrites that only rephrase add nothing Google can reward. Every refresh needs at least one new piece of information.
  • 410 vs 404: both remove the URL; 410 is a slightly faster signal. Neither passes link equity, so a URL with real backlinks should 301 to the closest relevant page instead.
  • A pruned page that still gets internal links becomes a broken link; crawl after pruning.

Verification

  • After deploy, live-fetch each changed URL: refreshed pages return 200 with the new content, consolidated URLs return a single-hop 301 to the winner, pruned URLs return 410 or carry noindex.
  • URL Inspection, “Request indexing” for the top 10 refreshed URLs (it is rate-limited).
  • At 4 and 8 weeks, compare clicks for the refreshed set against an untouched control set of similar pages over the same dates. Report the difference in change, not raw change, so seasonality and updates cancel out.
  • If a refreshed page dropped further after 8 weeks, diff the old and new versions for removed sections that carried queries (check the page+query export), and restore them.

Report template

## Content refresh — <site>, cycle <n> (YYYY-MM-DD)

Windows: <current> vs <YoY baseline>. Pages analyzed: <n>. Decayed (>= 25% clicks, >= 50 baseline): <n>.
Sitewide context: <core updates, migrations, tracking changes in the window>

| URL | Clicks base -> now | Main query position | Cause | Action | Priority | Status |
|---|---|---|---|---|---|---|

**Consolidations (needs confirmation):** winner <- losers, redirects listed
**Prunes (needs confirmation):** URL, 410 or noindex, reason, backlinks checked
**Measurement:** control group <n pages>, check dates <+4w>, <+8w>

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

scripts/decay_report.py 157 lines · 6.1 KB
#!/usr/bin/env python3
"""Flag decaying pages by comparing two Search Console page exports (stdlib only).

Each CSV needs a header row with a URL column (named like "Top pages", "Page",
"URL", or "Landing page") and a clicks column. Impressions and position columns
are optional but improve the pattern label.

Usage:
    python3 decay_report.py current.csv baseline.csv [--min-baseline-clicks 50]
                            [--drop 0.25] [--top 50] [--out decayed.csv]

Prints decayed pages sorted by clicks lost, with a likely pattern:
    vanished        page has no clicks/rows in the current window
    position-loss   average position worsened by >= 2
    ctr-loss        position roughly stable, impressions roughly stable, CTR fell
    demand-loss     impressions fell by about as much as clicks, position stable
    mixed           none of the above dominates
Exit 1 if a file is missing required columns or has no rows.
"""
from __future__ import annotations

import argparse
import csv
import sys
from urllib.parse import urlsplit

URL_KEYS = ("top pages", "page", "pages", "url", "landing page", "address")


def norm_url(u: str) -> str:
    p = urlsplit(u.strip())
    host = p.netloc.lower().removeprefix("www.")
    path = p.path.rstrip("/") or "/"
    return f"{host}{path}"


def num(v: str | None) -> float | None:
    if v is None:
        return None
    s = v.replace(",", "").replace("%", "").strip()
    if not s:
        return None
    try:
        return float(s)
    except ValueError:
        return None


def find_col(headers: list[str], *needles: str, exact: tuple[str, ...] = ()) -> str | None:
    low = {h: h.strip().lower() for h in headers}
    for h, l in low.items():
        if l in exact:
            return h
    for h, l in low.items():
        if any(n in l for n in needles):
            return h
    return None


def load(path: str) -> dict[str, dict[str, float | None]]:
    try:
        f = open(path, newline="", encoding="utf-8-sig")
    except OSError as e:
        sys.exit(f"error: cannot open {path}: {e}")
    with f:
        reader = csv.DictReader(f)
        headers = reader.fieldnames or []
        url_c = find_col(headers, exact=URL_KEYS) or find_col(headers, "url", "page")
        clk_c = find_col(headers, "click")
        imp_c = find_col(headers, "impression")
        pos_c = find_col(headers, "position")
        if not url_c or not clk_c:
            print(f"error: {path} needs a URL column and a clicks column; got {headers}", file=sys.stderr)
            sys.exit(1)
        rows: dict[str, dict[str, float | None]] = {}
        for r in reader:
            u = (r.get(url_c) or "").strip()
            if not u:
                continue
            key = norm_url(u)
            cur = rows.setdefault(key, {"url": u, "clicks": 0.0, "impr": None, "pos": None})
            cur["clicks"] = (cur["clicks"] or 0) + (num(r.get(clk_c)) or 0)
            if imp_c:
                cur["impr"] = (cur["impr"] or 0) + (num(r.get(imp_c)) or 0)
            if pos_c and cur["pos"] is None:
                cur["pos"] = num(r.get(pos_c))
    if not rows:
        print(f"error: {path} has no data rows", file=sys.stderr)
        sys.exit(1)
    return rows


def pattern(base: dict, cur: dict | None) -> str:
    if not cur or not cur["clicks"]:
        return "vanished"
    bp, cp = base.get("pos"), cur.get("pos")
    bi, ci = base.get("impr"), cur.get("impr")
    if bp is not None and cp is not None and cp - bp >= 2:
        return "position-loss"
    click_ratio = cur["clicks"] / base["clicks"]
    if bi and ci is not None:
        impr_ratio = ci / bi if bi else 1.0
        if impr_ratio >= 0.85:
            return "ctr-loss"
        if abs(impr_ratio - click_ratio) <= 0.15:
            return "demand-loss"
    return "mixed"


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("current")
    ap.add_argument("baseline")
    ap.add_argument("--min-baseline-clicks", type=float, default=50)
    ap.add_argument("--drop", type=float, default=0.25, help="fractional click drop to flag (0.25 = 25%%)")
    ap.add_argument("--top", type=int, default=50)
    ap.add_argument("--out")
    a = ap.parse_args()

    cur, base = load(a.current), load(a.baseline)
    flagged = []
    for key, b in base.items():
        if (b["clicks"] or 0) < a.min_baseline_clicks:
            continue
        c = cur.get(key)
        now = (c or {}).get("clicks") or 0
        change = (now - b["clicks"]) / b["clicks"]
        if change <= -a.drop:
            flagged.append((b["clicks"] - now, b["url"], b["clicks"], now, change, b.get("pos"),
                            (c or {}).get("pos"), pattern(b, c)))
    flagged.sort(reverse=True)

    total_base = sum(v["clicks"] or 0 for v in base.values())
    total_cur = sum(v["clicks"] or 0 for v in cur.values())
    site_change = (total_cur - total_base) / total_base if total_base else 0
    print(f"Site clicks: {total_base:.0f} -> {total_cur:.0f} ({site_change:+.0%})")
    if site_change <= -0.2:
        print("warning: sitewide drop of 20%+; check updates, migrations, tracking, and indexing before page-level work")
    print(f"Decayed pages: {len(flagged)} (baseline >= {a.min_baseline_clicks:.0f} clicks, drop >= {a.drop:.0%})\n")
    print(f"{'lost':>7} {'base':>7} {'now':>7} {'chg':>6} {'pos':>11}  pattern        url")
    for lost, url, b, n, chg, bp, cp, pat in flagged[: a.top]:
        fmt = lambda p: "-" if p is None else f"{p:.1f}"
        pos = f"{fmt(bp)}->{fmt(cp)}"
        print(f"{lost:7.0f} {b:7.0f} {n:7.0f} {chg:+6.0%} {pos:>11}  {pat:<14} {url}")

    if a.out:
        with open(a.out, "w", newline="", encoding="utf-8") as f:
            w = csv.writer(f)
            w.writerow(["url", "baseline_clicks", "current_clicks", "change", "baseline_pos", "current_pos", "pattern", "clicks_lost"])
            for lost, url, b, n, chg, bp, cp, pat in flagged:
                w.writerow([url, f"{b:.0f}", f"{n:.0f}", f"{chg:.3f}", bp if bp is not None else "", cp if cp is not None else "", pat, f"{lost:.0f}"])
        print(f"\nWrote {len(flagged)} rows to {a.out}")
    return 0


if __name__ == "__main__":
    sys.exit(main())

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc