Content gaps
Skill popiliadam/platinum-seo-engine/skills/discovery/content-gaps
Use when: kullanıcı "content gap", "içerik açığı", "rakip keyword bulalım", "yeni keyword fırsatı", "konu açığı analizi", "competitor keyword discovery", "yeni içerik için keyword araştırması", "ilgili keyword'leri çıkar" der ya da /pseo-content-gaps çağırır. DataForSEO keyword_ideas + related_keywords (+ optional content_analysis_search) raw JSON'larını inbox/dfs/ altına persist eder ve `_state/staging/` altına shape-adapted staging table yazar (Phase 8 cluster-map / new-content-plan konsume eder). Also use when: aktif projenin seed keyword listesi config'te tanımlı; TR (location_code=2792, language_code='tr') varsayılan; budget pre-flight PASS; yeni içerik planı (new-content-plan) için aday havuzu gerekiyor; cluster-map için keyword inventory besleniyor. Do not use when: GSC veri ingestion (gsc-pull), DFS volume lookup (dfs-pull), tek sayfa decay analizi (content-decay), pozisyon 11-20 fırsat taraması (quick-wins), URL çakışması (cannibalization) gerekiyor — ayrı discovery / ingestion skill'leri. Master.xlsx yokken çağırma; init- project önce çalışmalı (DURUR #4). Budget aşılmışsa fallback YASAK (DURUR #2).From its SKILL.md
npx -y skills add popiliadam/platinum-seo-engine --skill content-gapsAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
SKILL.md
20.3 KB, as published. Nobody here has run it
content-gaps — discovery skill (Phase 7 Wave 2, paid-MCP DFS HEAVY)
10-step protocol. Steps map 1:1 to workflow_runner invocations + the
spec §16.5 8-step MCP discipline + §16.8 budget pre-flight. Raw JSON
drift recovery is mandatory: every DFS response is dropped into
inbox/dfs/ before any transform runs, so a transform bug never
costs us the upstream payload (which is paid; re-fetch costs credits).
This skill follows the convention authority of
skills/ingestion/dfs-pull/SKILL.md (Phase 6 — staging-only D-003
pattern + REST/flat shape tolerance via _normalize_dfs_response) and
skills/discovery/cannibalization/SKILL.md (Wave 1 structural shape).
The 10-step protocol, raw JSON inbox discipline, budget pre-flight, and
provenance event format are reused verbatim — only the domain content
(target sources, transform script, gap_score heuristic) changes.
Deviate only with an ADR.
Inputs (frontmatter contract)
| Name | Type | Default | Notes |
|---|---|---|---|
project_slug | string | — | Required. Resolves projects/{slug}/master.xlsx. |
seed_keyword | string | — | Required. Drives DFS fetches; embedded in every staging row (cluster-map join key). |
location_code | integer | 2792 | DFS Turkey. |
language_code | string | "tr" | DFS Turkish. |
keyword_cap | integer | 5000 | DoS guard per MCP tool. |
use_content_analysis_search | boolean | false | Optional third source. |
workspace_root is resolved via PSEO_WORKSPACE_ROOT env or explicit
test override (mirrors workflow_runner / events_writer).
Outputs (artifacts produced)
projects/{slug}/_state/staging/content_gaps_keyword_ideas_{date}_{slug}.json— keyword_ideas candidate staging table (schema-versioned, sha256 content_hash per row, ISO 8601 microsecondfetched_at). Columns fromcontent_gaps_transform.CONTENT_GAPS_COLUMNS.projects/{slug}/_state/staging/content_gaps_related_keywords_{date}_{slug}.json— related_keywords candidate staging table.projects/{slug}/_state/staging/content_gaps_content_analysis_search_{date}_{slug}.json— optional content_analysis_search staging table (only when inputuse_content_analysis_search=true).projects/{slug}/outputs/reports/{date}-content-gaps.md— human-readable summary (top gap_score keywords, source breakdown).projects/{slug}/_state/events.jsonl—event_kind=provenanceentries (source.kind=dataforseo_mcp,target_excel_sheet=null— staging-only).projects/{slug}/inbox/dfs/{date}-keyword_ideas-gaps-{slug}.json— raw labs payload (drift recovery).projects/{slug}/inbox/dfs/{date}-related_keywords-gaps-{slug}.json— raw labs payload.
Note: content-gaps does NOT write to
master.xlsx. Phase 8cluster-mapandnew-content-planskills consume the staging tables and project tomaster.xlsx#cluster_keywords/new_content_plandownstream (F-09 shared writer disiplini orada uygulanır).
D-003 staging-only routing (paterni: dfs-pull)
The Phase 6 D-003 lesson is enforced verbatim here:
- Discovery skills that produce candidate inventory route to
_state/staging/only. Notransaction.append. No master.xlsx write. - Phase 8 planning skills (
cluster-map,new-content-plan) consume staging tables and project the chosen subset into Excel — they own the F-09 shared-writer discipline for sheets with multiple writers. - Re-running content-gaps does NOT touch master.xlsx; only the staging JSON is overwritten (idempotent: same input → byte-stable file).
Shape adapter (_normalize_dfs_response): runtime tolerates two DFS
response shapes — REST envelope tasks[0].result[0].items AND wrapper-
flattened items — by importing the function from
scripts.ingestion.dfs_pull (not copying). Any future shape fix in
dfs-pull is inherited automatically; KOPYA YASAK is enforced by the
import-discipline test.
10-Step Body Protocol
Each step name must match the
steps[*].namepassed toworkflow_runner.create_run. Names are stable identifiers across runs.
Step 1 — preflight_budget (§16.8, MANDATORY for paid-MCP skills)
Subprocess wrapper around scripts/budget/check_budget.py (W-A3/W-A4
paterni). DURUR #2 if exceeded — never silently downgrade.
from scripts.discovery import content_gaps_transform as cgt
envelope = cgt.preflight_budget(
project_config_path=project_root / "project.config.json",
events_path=project_root / "_state" / "events.jsonl",
)
# envelope == {"budget_per_day": 500, "used_24h": 15, "remaining": 485, "exceeded": false}
The wrapper raises BudgetExceededError when the underlying script
exits non-zero. The error inherits from ContentGapsError so a single
except handles every DURUR class this skill emits.
Step 2 — create_run
Open a workflow run shell. The state file lives at
projects/{slug}/_state/workflows/{run_id}.json (ADR-021).
from scripts.state import workflow_runner
handle = workflow_runner.create_run(
skill="content-gaps",
project_slug=project_slug,
steps=[
{"name": "preflight_budget"},
{"name": "fetch_keyword_ideas"},
{"name": "fetch_related_keywords"},
{"name": "fetch_content_analysis_search"}, # optional
{"name": "transform"},
{"name": "request_approval"},
{"name": "write_staging"},
{"name": "render_report"},
],
)
Step 3 — fetch_keyword_ideas (MCP §16.5 step 3 — raw inbox first)
workflow_runner.start_step(handle.run_id, 1, project_slug=project_slug)
raw_ideas = mcp__dataforseo__dataforseo_labs_google_keyword_ideas(
keywords=[seed_keyword],
location_code=location_code, # 2792 = Turkey
language_code=language_code, # "tr"
limit=keyword_cap,
)
inbox_path = (
workspace_root / "projects" / project_slug
/ "inbox" / "dfs"
/ f"{today.isoformat()}-keyword_ideas-gaps-{project_slug}.json"
)
inbox_path.parent.mkdir(parents=True, exist_ok=True)
inbox_path.write_text(json.dumps(raw_ideas, ensure_ascii=False, indent=2))
workflow_runner.finish_step(handle.run_id, 1, project_slug=project_slug,
output_ref=str(inbox_path))
Step 4 — fetch_related_keywords
raw_related = mcp__dataforseo__dataforseo_labs_google_related_keywords(
keyword=seed_keyword,
location_code=location_code,
language_code=language_code,
depth=2,
limit=keyword_cap,
)
related_inbox = (
workspace_root / "projects" / project_slug
/ "inbox" / "dfs"
/ f"{today.isoformat()}-related_keywords-gaps-{project_slug}.json"
)
related_inbox.write_text(json.dumps(raw_related, ensure_ascii=False, indent=2))
Step 5 — fetch_content_analysis_search (OPTIONAL)
Only when the caller passes use_content_analysis_search=true.
raw_cas = None
if use_content_analysis_search:
raw_cas = mcp__dataforseo__content_analysis_search(
keyword=seed_keyword,
location_code=location_code,
language_code=language_code,
)
cas_inbox = (
workspace_root / "projects" / project_slug / "inbox" / "dfs"
/ f"{today.isoformat()}-content_analysis_search-gaps-{project_slug}.json"
)
cas_inbox.write_text(json.dumps(raw_cas, ensure_ascii=False, indent=2))
Step 6 — transform
Pure compute via scripts/discovery/content_gaps_transform.py
(staging-only output). The transform calls the imported
_normalize_dfs_response() first to flatten REST or wrapper shapes
uniformly, then builds one staging payload per source:
python3 scripts/discovery/content_gaps_transform.py \
--raw-keyword-ideas inbox/dfs/{date}-keyword_ideas-gaps-{slug}.json \
--raw-related-keywords inbox/dfs/{date}-related_keywords-gaps-{slug}.json \
--seed-keyword "{seed}" \
--keyword-cap 5000 \
--staging-dir _state/staging/ \
--project-slug {slug}
Produces 2-3 staging JSON files (content_gaps_keyword_ideas_*.json,
content_gaps_related_keywords_*.json, optionally
content_gaps_content_analysis_search_*.json) — each with
schema_version, tool, seed_keyword, fetched_at (ISO 8601 µs
UTC), row_count, columns, rows[] (1-indexed id + sha256
content_hash).
Gap score formula (transform domain)
gap_score = (search_volume / (1 + keyword_difficulty)) * (1 - competition_norm)
competition_norm:
numeric → clipped to [0, 1] (DFS competition_index sometimes 0..100;
collapsed by /100)
LOW → 0.20
MEDIUM → 0.55
HIGH → 0.90
unknown → 0.50 (mid-band default)
Coarse but deterministic. Higher volume → higher score; higher
difficulty / higher competition → lower score. Pluggable: callers may
substitute a custom scorer; the staging column stays gap_score
regardless. Result is rounded to 4 decimals for staging idempotency.
Rows are sorted by gap_score desc, ties broken by keyword asc.
Step 7 — request_approval (skill EXIT awaiting_approval)
workflow_runner.request_approval(
handle.run_id, project_slug=project_slug,
approver="user",
subject=f"{seed_keyword} için {total_candidates} aday keyword bulundu, _state/staging/'e yazalım mı?",
step_index=5,
)
# Skill exits here. The user replies in a fresh session; resume below.
Step 8 — write_staging (atomic, schema-validated)
content_gaps_transform.write_staging() writes one self-contained
staging JSON per source (overwrite-idempotent) under
projects/{slug}/_state/staging/. Schema validated by
_validate_staging_payload() before write — StagingSchemaError
(IDENTITY-shared with dfs-pull) on drift. NO transaction.append
here; staging-only.
from scripts.discovery import content_gaps_transform as cgt
written = cgt.write_staging(
result,
staging_dir=workspace_root / "projects" / project_slug / "_state" / "staging",
project_slug=project_slug,
date=today.isoformat(),
)
# written == {"keyword_ideas": "...", "related_keywords": "...", ["content_analysis_search": "..."]}
Step 9 — Provenance event + report
from scripts.state import events_writer
events_writer.append_provenance(
project_id=project_slug,
source={"kind": "dataforseo_mcp", "mcp_server": "dataforseo",
"mcp_tool": "dataforseo__dataforseo_labs_google_keyword_ideas",
"response_bytes": len(json.dumps(raw_ideas))},
operation="ingest",
target_table="content_gaps_keyword_ideas",
target_excel_sheet=None, # staging-only; Phase 8 cluster-map projects to Excel
rows_written=0, # staging-only: no Excel rows yet
cost={"provider": "dataforseo",
"credits": float(estimated_credits),
"budget_key": "project.config.dataforseo.budget_credits_per_day"},
)
# Repeat for related_keywords (and content_analysis_search when used).
render_template.py templates/reports/content-gaps.template.md data.json
→ outputs/reports/{date}-content-gaps.md. Variables: $project_slug,
$date, $seed_keyword, $location_code, $language_code,
$total_candidates, $top_keyword, $top_gap_score,
$source_breakdown, $top1_* … $top3_*, $estimated_credits,
$budget_preflight_status, $staging_path_ideas,
$staging_path_related, $run_id.
Step 10 — complete
workflow_runner.complete(handle.run_id, project_slug=project_slug, outputs={
# F5: outputs.* must be STRING-TYPED.
"ideas_staging_rows": str(result["keyword_ideas"]["row_count"]),
"related_staging_rows": str(result["related_keywords"]["row_count"]),
"staging_files": ";".join(written.values()),
"seed_keyword": seed_keyword,
"credits_used": str(estimated_credits),
"report_path": str(report_path),
"raw_jsons": ";".join([str(inbox_path), str(related_inbox)]),
})
DURUR conditions (9)
Stop and flag the manager — do not patch, do not fall back.
mcp__dataforseo__dataforseo_labs_google_keyword_ideasOR…_related_keywordsreturns auth/network/scope error.scripts/budget/check_budget.pyexits non-zero → subprocess wrapper raisesBudgetExceededError. STOP, await manager approval to lift the cap (W-A3/W-A4 paterni).- REST/flat shape both fail — imported
_normalize_dfs_responseraisesValueError("Unrecognized DFS response shape"). STOP, the wrapper version drift is an ADR-level event. _state/staging/path cannot be created underprojects/{slug}/(workspace path missing or read-only).- Keyword count from MCP >
keyword_cap(default 5000) →KeywordCapExceededError. DoS prevention; never project a runaway payload. inbox/dfs/path cannot be created (workspace path missing or non-writable).PSEO_WORKSPACE_ROOTenv var unset and no explicitworkspace_rootarg passed toworkflow_runner/events_writer. STOP, surface to manager.workflow_runner.create_runfails schema validation (schemas/workflow-run.schema.json).- Both required MCPs return 0 candidate rows for the seed →
NoCandidatesError. The skill refuses to silently emit empty staging — surfaces "no content gaps found for seed=…" so the manager can pick a different seed.
(Defensive #5 mirror: StagingSchemaError from imported dfs-pull
re-validation also DURURs the run if a row drifts before disk write.)
Cross-references
- Schemas:
schemas/dataforseo-endpoint-mapping.schema.json(DFS contract; cost.credits_per_call),schemas/events.schema.json(source.kind=dataforseo_mcp,target_excel_sheet=nullfor staging-only ops),schemas/skill-frontmatter.schema.json(this frontmatter;budget.additionalProperties:falseenforced). - Cross-modules (IMPORT-only):
scripts/state/workflow_runner.py,scripts/state/events_writer.py,scripts/budget/check_budget.py(subprocess wrapper). Fromscripts/ingestion/dfs_pull.py:_normalize_dfs_responseANDStagingSchemaErrorare imported — KOPYA YASAK; identity preserved by the import-discipline test. Note:scripts/excel/transaction.pyis NOT imported (staging-only). - Transform:
scripts/discovery/content_gaps_transform.py(gap_score,transform,write_staging,staging_filename,_staging_table_dict,_validate_staging_payload,CONTENT_GAPS_COLUMNS,preflight_budget). - Tests:
tests/skills/test_content_gaps.py(≥6 cases incl. import- discipline identity check). - Template:
templates/reports/content-gaps.template.md. - Phase 8
cluster-mapskill consumes_state/staging/content_gaps_*.jsonand applies F-09 shared-writer discipline when projecting tomaster.xlsx#cluster_keywords/new_content_planalongside dfs-pull staging.
Discipline checklist
- TODO/fallback YASAK — every DURUR raises, none silently downgrade.
- Schema-first — frontmatter validates against
schemas/skill-frontmatter.schema.jsonDraft 7. Budget block conforms toadditionalProperties:false(Q-W-A4-01: onlyuses_paid_mcp+estimated_credits; no_per_call/_per_urlleakage). - Plugin-agnostik — no slug literals;
project_slugflows through every path; transform has 0 hardcoded slug words. - ADR-013:
Use when/Also use when/Do not use whenare STRING content insidedescription, not separate fields. - Budget pre-flight integration —
scripts/budget/check_budget.pyinvoked at Step 1 (subprocess wrapper) BEFORE any paid call. - Append-only — all events via
events_writer; staging JSON is overwrite-idempotent (re-run produces stable id/content_hash); Phase 8 owns the F-09 shared-writer discipline downstream. - Cross-module IMPORT discipline —
workflow_runner/events_writerare imported, never modified. Fromdfs_pull:_normalize_dfs_responseANDStagingSchemaErrorare imported (KOPYA YASAK enforced by identity test).scripts.excel.transactionis NOT imported (staging-only — D-003 resolution). - DoS guard —
keyword_capdefault 5000 (configurable per-run); MCP responses larger than the cap →KeywordCapExceededError. - F1: write target is
_state/staging/(no master.xlsx from this skill — Phase 8 cluster-map / new-content-plan owns the projection). - F5:
outputs.*values are STRING-TYPED (artifact paths or stringified counts), never raw ints.