| name | content-decay |
| description | 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).
|
| version | 1.0 |
| status | active |
| category | discovery |
| inputs | {"project_slug":{"type":"string","required":true,"description":"Slug; resolves projects/{slug}/master.xlsx + project.config.json."},"days_back":{"type":"integer","required":false,"default":90,"description":"Recent window length in days; previous window has equal length."},"dimensions":{"type":"array","required":false,"description":"GSC dimensions (default: ['page'])."}} |
| outputs | ["master.xlsx#content_decay","outputs/reports/{date}-content-decay.md","events.jsonl","inbox/gsc/{date}-enhanced_search_analytics-decay-recent-{slug}.json","inbox/gsc/{date}-enhanced_search_analytics-decay-previous-{slug}.json","inbox/dfs/{date}-historical_rank_overview-decay-{slug}.json"] |
| consumes | ["init-project:projects/{slug}/master.xlsx","gsc-pull:master.xlsx#gsc_performance"] |
| produces | ["new-content-plan","drift-check"] |
| triggers | {"manual":["/pseo-content-decay"],"natural_language":"\"içerik decay\", \"content decay\", \"tıklama düşen sayfalar\", \"ctr düştü\",\n\"90 gün öncesine göre azalma\", \"trafik kaybeden sayfalar\",\n\"decay analizi\", \"content_decay sheet yenile\"\n","hooks":[],"scheduled":[{"cron":"0 7 * * 1","mode":"report-only"}]} |
| mcp_tools | {"required":["mcp__gsc__enhanced_search_analytics"],"optional":["mcp__dataforseo__dataforseo_labs_google_historical_rank_overview"]} |
| budget | {"uses_paid_mcp":false,"estimated_credits":0} |
| autonomy | {"confidence":"HIGH","requires_approval":true,"safe_auto_execute":false} |
content-decay — discovery skill (Phase 7 Wave 1)
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.
Inputs (frontmatter contract)
| 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).
Outputs (artifacts produced)
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.
- (optional)
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).
10-Step Body Protocol
Each step name must match the steps[*].name passed to
workflow_runner.create_run. Names are stable identifiers across runs.
Step 1 — create_run
Open 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"},
],
)
Step 2 — 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,
)
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))
Step 3 — 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).
Step 4 — transform
Pure 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.
Step 4b — 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.
- Budget pre-flight PASS → fetch DFS historical rank, persist raw to
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.
- Budget pre-flight FAIL → DURUR #7 (
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,
)
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")
Step 5 — 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,
)
Step 6 — Resume (approve → continue)
workflow_runner.approve(handle.run_id, project_slug=project_slug,
approver="user")
Step 7 — 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",
)
Step 8 — render_report
render_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.
Step 9 — Provenance event
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),
)
Step 10 — complete
workflow_runner.complete(handle.run_id, project_slug=project_slug, outputs={
"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),
})
URL normalization (D-03 invariant)
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.
Period delta semantics
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).
DURUR conditions (9)
Stop and flag the manager — do not patch, do not fall back.
- Either
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.
- Raw JSON inbox path cannot be created (workspace path missing or
non-writable).
- URL normalization output drifts (idempotency check fails inside
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.
- Optional DFS
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).
- Both windows aggregate to zero rows (
ContentDecayError: no data in window). Decay-specific: there is no signal to project.
- Window dates overlap (caller logic error; recent.start ≤
previous.end). Decay-specific guard against silently double-counting.
The skill orchestrator validates window arithmetic before fetch.
PSEO_WORKSPACE_ROOT env unset → also a STOP (inherited from
workflow_runner / events_writer discipline; folded into DURUR #2's
"workspace path missing" surface).
Cross-references
- Schemas:
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).
- Cross-modules (IMPORT-only):
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).
- Transform:
scripts/discovery/content_decay_transform.py
(normalize_url, infer_pillar, transform,
CONTENT_DECAY_COLUMNS, ContentDecayError).
- Tests:
tests/skills/test_content_decay.py (≥6 cases incl. trend
coverage, ±100 clamp, sort, schema column match, smoke E2E).
- Template:
templates/reports/content-decay.template.md.
Discipline checklist