From platinum-seo-engine
Use when: kullanıcı "içerik decay", "content decay", "tıklama düşen sayfalar", "ctr düştü", "90 gün öncesine göre azalma", "trafik kaybeden sayfalar", "decay analizi", "content_decay sheet yenile" der ya da /pseo-content-decay çağırır. Also use when: aktif projenin GSC verisi master.xlsx'te mevcut (gsc-pull önce çalışmış); 90-günlük recent + previous pencere karşılaştırması yapılacak; pillar bazlı içerik decay haritası lazım; downstream content-improve / new-content-plan skill'leri bu listeyi tüketecek. Do not use when: quick-win opportunity scoring (quick-wins skill), GSC delta ingestion (gsc-pull skill), tek-URL audit (tech-audit skill), cannibalization analizi — ayrı discovery skill'leri. Master.xlsx yokken çağırma; init-project + gsc-pull önce çalışmalı (DURUR #6).
How this skill is triggered — by the user, by Claude, or both
Slash command
/platinum-seo-engine:content-decayThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
10-step protocol. Steps map 1:1 to `workflow_runner` invocations + the
10-step protocol. Steps map 1:1 to workflow_runner invocations + the
spec §16.5 8-step MCP discipline. Raw JSON drift recovery is mandatory:
every MCP response is dropped into inbox/gsc/ (or inbox/dfs/ for
the optional historical-rank cross-check) before any transform runs,
so a transform bug never costs us the upstream payload.
This skill follows the convention authority established by
skills/discovery/quick-wins/SKILL.md + the Phase 6 GSC ingestion
paterni skills/ingestion/gsc-pull/SKILL.md. The 10-step protocol shape,
raw JSON inbox discipline, URL normalization (D-03), DURUR + flag rule,
and provenance event format are reused verbatim — only the domain content
(window semantics, transform script, target sheet, optional DFS branch)
changes. Deviate only with an ADR.
| Name | Type | Default | Notes |
|---|---|---|---|
project_slug | string | — | Required. Resolves projects/{slug}/master.xlsx. |
days_back | integer | 90 | Recent window end=today, start=today-N. Previous window same length. |
dimensions | array | ['page'] | GSC dimensions for enhanced_search_analytics calls. |
workspace_root is resolved via PSEO_WORKSPACE_ROOT env or explicit
test override (mirrors workflow_runner / events_writer).
projects/{slug}/master.xlsx#content_decay — per-page recent vs
previous window decay rows (8 cols, schema-locked).projects/{slug}/outputs/reports/{date}-content-decay.md — human-
readable summary (top decayed pages, trend distribution, pillars).projects/{slug}/_state/events.jsonl — event_kind=provenance
entries (source.kind=gsc_mcp; one per MCP call, plus optionally one
dataforseo_mcp entry when the optional rank cross-check fires).projects/{slug}/inbox/gsc/{date}-enhanced_search_analytics-decay-recent-{slug}.json
— raw MCP payload for the recent 90-day window (drift recovery).projects/{slug}/inbox/gsc/{date}-enhanced_search_analytics-decay-previous-{slug}.json
— raw MCP payload for the previous 90-day window.projects/{slug}/inbox/dfs/{date}-historical_rank_overview-decay-{slug}.json
— raw DFS historical rank cross-check payload (only when the
optional branch is invoked AND its budget pre-flight passes).Each step name must match the
steps[*].namepassed toworkflow_runner.create_run. Names are stable identifiers across runs.
create_runOpen a workflow run shell. The state file lives at
projects/{slug}/_state/workflows/{run_id}.json (ADR-021, ADR-019).
from scripts.state import workflow_runner
handle = workflow_runner.create_run(
skill="content-decay",
project_slug=project_slug,
steps=[
{"name": "fetch_recent"},
{"name": "fetch_previous"},
{"name": "transform"},
{"name": "request_approval"},
{"name": "write_excel"},
{"name": "render_report"},
],
)
fetch_recent (MCP §16.5 step 3 — raw inbox first)Recent window (today - days_back .. today).
workflow_runner.start_step(handle.run_id, 0,
project_slug=project_slug)
raw_recent = mcp__gsc__enhanced_search_analytics(
siteUrl=project_config["gsc"]["site_url"],
startDate=(today - days_back).isoformat(),
endDate=today.isoformat(),
dimensions=dimensions, # default: ["page"]
)
inbox_recent = (
workspace_root / "projects" / project_slug
/ "inbox" / "gsc"
/ f"{today.isoformat()}-enhanced_search_analytics-decay-recent-{project_slug}.json"
)
inbox_recent.parent.mkdir(parents=True, exist_ok=True)
inbox_recent.write_text(json.dumps(raw_recent, ensure_ascii=False, indent=2))
workflow_runner.finish_step(handle.run_id, 0,
project_slug=project_slug,
output_ref=str(inbox_recent))
fetch_previous (previous window for delta — MUST succeed)mcp__gsc__enhanced_search_analytics covering the PREVIOUS window of
equal length (today - 2*days_back .. today - days_back). Persist to
inbox/gsc/{date}-enhanced_search_analytics-decay-previous-{slug}.json.
Both windows are required. Unlike gsc-pull (where previous-window failure is non-fatal first-run), decay analysis has no signal without the comparison baseline. Failure here triggers DURUR #1 (do NOT proceed with one-window data).
transformPure compute via scripts/discovery/content_decay_transform.py:
python3 scripts/discovery/content_decay_transform.py \
--recent inbox/gsc/{date}-enhanced_search_analytics-decay-recent-{slug}.json \
--previous inbox/gsc/{date}-enhanced_search_analytics-decay-previous-{slug}.json \
--output-dir _state/transform/{run_id}/
Produces a JSON array (content_decay) shaped to the master-excel
schema. URL normalization (D-03) is applied here, not at fetch time, so
the raw inbox copy is byte-faithful to the MCP response. The transform
is idempotent: same inputs → byte-identical output. Sort: clicks_delta
ascending (most decayed first), then url asc for tie-break determinism.
optional_dfs_cross_check (only when invoked)When the optional mcp__dataforseo__dataforseo_labs_google_historical_rank_overview
branch is active, first run the budget pre-flight via
scripts/budget/check_budget.py against the project's 24h DFS credit
total.
inbox/dfs/{date}-historical_rank_overview-decay-{slug}.json,
enrich action with a short note (e.g. "rank also dropped in DFS")
for rows where the DFS series confirms the decay direction.StopAndAwaitApproval). Do
NOT silently fall back to GSC-only. The skill exits with
awaiting_approval; the manager must explicitly authorize either
raising the budget cap or skipping the cross-check (recorded as an
ADR), then resume.from scripts.budget import check_budget
budget_envelope = check_budget.preflight(
project_config_path=project_root / "project.config.json",
events_path=project_root / "_state" / "events.jsonl",
estimated_credits=1, # one DFS historical_rank_overview call
)
if budget_envelope["exceeded"]:
workflow_runner.request_approval(
handle.run_id, project_slug=project_slug,
approver="user",
subject="DFS historical_rank cross-check budget aşıldı; cap yükseltilsin mi?",
step_index=3,
)
raise check_budget.BudgetGateError("DFS budget pre-flight failed; awaiting_approval")
request_approval (skill EXIT awaiting_approval)workflow_runner.request_approval(
handle.run_id, project_slug=project_slug,
approver="user",
subject=f"Content decay {len(rows)} URL hesaplandı, master.xlsx#content_decay'e yazalım mı?",
step_index=3,
)
# Skill exits here. The user replies in a fresh session; resume below.
approve → continue)workflow_runner.approve(handle.run_id, project_slug=project_slug,
approver="user")
write_excel (atomic, schema-validated)Single idempotent committer.commit call for the content_decay sheet
(replace — re-running refreshes the snapshot, never duplicates rows; routes
through transaction.py: backup + lock + schema validation + provenance).
Note: the transform module itself does NOT import the committer
(scripts.orchestration.committer) — only the skill orchestrator layer does
(cross-module IMPORT discipline).
from scripts.orchestration import committer
committer.commit(
workbook_path=workspace_root/"projects"/project_slug/"master.xlsx",
sheet="content_decay",
rows=content_decay_rows,
run_id=handle.run_id,
project_slug=project_slug,
writer="content-decay",
)
render_reportrender_template.py templates/reports/content-decay.template.md data.json
→ outputs/reports/{date}-content-decay.md. Variables (all 13 the template
references — render hard-fails on any missing): $project_slug, $date,
$days_back, $run_id, $total_urls, $rows_written, $decay_count,
$retired_count, $top_decay_url, $top_decay_delta, $trend_distribution,
$pillar_summary, $report_summary.
One provenance entry per MCP call (two for GSC, plus one DFS when the optional branch fires).
from scripts.state import events_writer
events_writer.append_provenance(
project_id=project_slug,
source={"kind": "gsc_mcp", "mcp_server": "gsc",
"mcp_tool": "gsc__enhanced_search_analytics",
"response_bytes": len(json.dumps(raw_recent))},
operation="project_excel",
target_excel_sheet="content_decay",
rows_written=len(content_decay_rows),
)
# Repeat for raw_previous (also gsc_mcp). When DFS branch ran:
# source.kind=dataforseo_mcp + cost.credits=1 + budget_key.
completeworkflow_runner.complete(handle.run_id, project_slug=project_slug, outputs={
# F5: outputs.* must be STRING-TYPED.
"content_decay_rows": str(len(content_decay_rows)),
"decay_count": str(meta["trend_counts"]["DECAY"]),
"retired_count": str(meta["trend_counts"]["RETIRED"]),
"report_path": str(report_path),
"raw_recent": str(inbox_recent),
"raw_previous": str(inbox_previous),
})
Every URL passing through this skill is normalized via
scripts.discovery.content_decay_transform.normalize_url. The function
is idempotent: normalize_url(normalize_url(u)) == normalize_url(u).
Rules: lowercase scheme+host, IDN→punycode, strip default ports, strip
trailing slash (root excluded), drop fragment, drop tracking params,
sort remaining query keys.
The transform self-checks idempotency on every emitted URL — a drift between transform and downstream consumers is escalated as DURUR #3 rather than silently masked.
recent window: [today - days_back, today] (default 90d)
previous window: [today - 2*days_back, today - days_back]
Both windows must have equal length. The transform aggregates clicks +
impressions + impression-weighted position per normalized URL across each
window, then labels the trend per R-85 (single source —
rules/content-update-discipline.md). The clicks delta_pct is still the
emitted column; position/impressions feed the DECISION only (the 8-column
schema is unchanged).
clicks_delta = clicks_recent - clicks_previous
delta_pct = (clicks_recent - clicks_previous) / clicks_previous * 100,
rounded 2dp; clamped to ±100 when previous=0
(mirrors gsc-pull's _delta_pct, Phase 6 paterni)
trend (R-85 multi-signal — see rules/content-update-discipline.md §R-85):
NEW if previous clicks=0 and recent>0
RETIRED if recent clicks=0 and previous>0
GROWTH if clicks delta_pct ≥ +20
DECAY if (clicks Δ% < clicks_threshold AND position worsened > position_threshold)
OR (impressions Δ% < -40% AND ranking trend negative)
STABLE otherwise ← a clicks-only drop with NO position/impression
corroboration is STABLE, not DECAY (single-signal
volatility ≠ decay — R-85 rationale)
profile-aware thresholds (clicks/position branch; impressions branch fixed):
YMYL : clicks_threshold -20%, position_threshold +3 (stricter)
e-commerce : -30% / +5
other/default: -30% / +5
(resolved via scripts/util/profile_aware_defaults.cascade_default — a
project.config tuning key or an explicit override wins over the profile
default; meta records the resolved thresholds + which R-85 branch fired.)
pillar = first non-empty path segment (e.g. /blog/x → "blog")
action = trend → deterministic prescription
DECAY → "investigate + refresh"
STABLE → "monitor"
GROWTH → "double-down"
NEW → "new content tracking"
RETIRED → "redirect or revive review"
YoY mode (--yoy): compares the recent window against the same window
one year earlier (the --previous payload is that year-ago window). When
the year-ago data is present it is used as the decay baseline; when it is
absent the transform refuses to fabricate verdicts — it emits a
meta.yoy_unavailable=true note and zero rows (never fakes a YoY baseline).
Stop and flag the manager — do not patch, do not fall back.
mcp__gsc__enhanced_search_analytics call (recent OR
previous) returns auth/network/scope error. Decay analysis has no
signal without both windows; STOP — do not proceed with one-window
data.content_decay_transform.transform).master.xlsx#content_decay column count or names don't match
schema (schemas/master-excel.schema.json#content_decay,
8 required_columns).committer.commit raises RowSchemaError for content_decay.workflow_runner.create_run fails schema validation.historical_rank_overview invoked AND budget pre-
flight FAILS → STOP, transition to awaiting_approval. Manager
bilgilendirilir; do NOT silently fall back to GSC-only (false-success
failure mode the brief explicitly forbids).ContentDecayError: no data in window). Decay-specific: there is no signal to project.PSEO_WORKSPACE_ROOT env unset → also a STOP (inherited from
workflow_runner / events_writer discipline; folded into DURUR #2's
"workspace path missing" surface).
schemas/master-excel.schema.json (content_decay sheet,
8 required_columns at lines 173-180), schemas/events.schema.json
(source.kind=gsc_mcp / dataforseo_mcp,
target_excel_sheet=content_decay),
schemas/cross-sheet-invariants.json (D-03 invariant),
schemas/skill-frontmatter.schema.json (this frontmatter).scripts/state/workflow_runner.py,
scripts/excel/transaction.py, scripts/state/events_writer.py,
scripts/reporting/render_template.py,
scripts/budget/check_budget.py (optional DFS branch only —
not for the GSC-only path).scripts/discovery/content_decay_transform.py
(normalize_url, infer_pillar, transform,
CONTENT_DECAY_COLUMNS, ContentDecayError).tests/skills/test_content_decay.py (≥6 cases incl. trend
coverage, ±100 clamp, sort, schema column match, smoke E2E).templates/reports/content-decay.template.md.awaiting_approval,
not a silent GSC-only fallback.schemas/skill-frontmatter.schema.json Draft 7.project_slug flows through.
0-tolerance ADR-008 + D-010 Path B.Use when/Also use when/Do not use when are STRING
content inside description, not separate fields.transaction /
workflow_runner / events_writer / check_budget are imported,
never modified from this skill. The transform module itself does
NOT import scripts.excel.transaction (skill-orchestrator-only).master.xlsx (lowercase, schema-shaped).outputs.* values are STRING-TYPED (artifact paths or
stringified counts), never raw ints.events_writer.append_provenance,
one per MCP call; workflow run JSON only forward-flips step status.npx claudepluginhub popiliadam/platinum-seo-engine --plugin platinum-seo-engineScans content library for decay signals (declining traffic, falling rankings, outdated stats, lost AI citations) and prioritizes refresh opportunities by business impact. Useful for identifying stale content and recovering lost traffic.
Monitors existing content for ranking drops and generates a prioritized refresh queue. Run monthly or after an SEO audit to recover lost positions.
Use when: kullanıcı "content prune yap", "decay action uygula", "URL retire et", "301/410 patch", "robots disallow ekle" der ya da `/pseo-content-remediation` çağırır. master.xlsx[content_decay] satırından action="prune" / "redirect" / "delete" alındığında, R-85 multi-signal threshold verify edilir, R-90 manuel approve gate zorunlu, R-91 301/410 decision tree branch'lenir, 3 sheet writer (redirect_404 + robots_txt + completed_work) atomic transaction. Also use when: consistency report FAIL trigger var ve content audit (R-44..R-54 source verification fail) sonucu URL retire gerekiyor; content_improve sheet'inde priority=HIGH FAIL durumlar legacy cleanup için; project-config[profile] enum 5-değer (e-commerce | ymyl | local-service | b2b-saas | portfolio) içinden biri sabit ve profile-aware approve gate uygulanacak (Principle 2). Do not use when: mevcut blog drift fix için (revise-content skill domain — content-remediation = RETIRE scope only); FAQ block re-render için (faq-optimization Wave 2 kullan); yeni blog üretimi için (new-blog kullan); image generation için (generate-images kullan); content_decay.action == "revise" (DURUR #4, wrong skill); R-85 multi-signal threshold satisfied değil (DURUR #1, single- signal remediation YASAK); R-90 manuel approve YOK (DURUR #2); action="redirect" + target_url=null (DURUR #3, R-91 decision tree reject).