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.
- name
content-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.
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
dateModifiedand 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 size | Source | Notes |
|---|---|---|
| < 1,000 URLs | Performance report, Pages tab, Compare mode, export CSV | UI caps at 1,000 rows per export |
| 1,000-50,000 URLs | Search Analytics API, dimensions: ["page"] then ["page","query"] | 25,000 rows per request, paginate with startRow |
| Larger or recurring | Bulk data export to BigQuery | Includes 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 data | Likely cause | Fix category |
|---|---|---|
| Position dropped 3+ places on main queries, competitors now fresher or deeper | Content fell behind | Refresh |
| Position dropped, SERP now shows a different format (tools, videos, lists, product pages) | Intent shifted | Rewrite or retarget |
| Position stable, impressions stable, CTR down | SERP features or an AI Overview absorbing clicks | Rewrite title and meta, add a quotable answer block; accept partial loss |
| Impressions down on all queries, position stable | Demand fell (fad, product renamed, season) | Leave, or retarget to the new terminology |
| Two of your URLs alternate on the same queries | Cannibalization | Consolidate |
| Clicks lost on facts that changed (prices, versions, laws, screenshots) | Outdated information | Refresh |
| Page lost rankings after a core update along with similar thin pages | Helpfulness or quality judgment | Rewrite with first-hand evidence, or prune the class |
| Page dropped out of the index entirely | Technical 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
| Action | Use when | What it involves |
|---|---|---|
| Refresh | Intent unchanged, content outdated or thinner than current top results | Update facts, add missing subtopics, new examples, current screenshots, tighter intro |
| Rewrite | Intent shifted, or the page fails a helpfulness read | New outline from today’s SERP (use seo-content-briefs), same URL |
| Consolidate | Two or more pages target the same intent | Merge the best material into the strongest URL, 301 the rest to it |
| Prune: 410 | No traffic, no links, no conversions, no plausible future value | Remove, return 410, drop from sitemap and internal links |
| Prune: noindex | Needed by users (old changelogs, event recaps) but not search-worthy | Keep live, noindex,follow, out of the sitemap |
| Leave | Decline 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:
- 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.
- Fix every stale fact, version number, price, screenshot, and broken outbound link.
- 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.
- Put a direct 40-60 word answer to the main query directly under the H1 or the first H2.
- Cut sections that no longer serve the intent; shorter often ranks better than padded.
- 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.
- 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.
- Update the visible date and
dateModifiedin 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
#!/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.