--- name: content-planner argument-hint: "" description: > GSC-driven content calendar. Pulls real Search Console data, finds the highest click-potential opportunities (striking-distance queries at positions 5-20, unanswered query intent, related-keyword expansions), and produces a dated, prioritized content calendar — ready to hand to /content-writer. Use when the user asks "plan my content", "what should I write next", "content calendar", "content plan", "content roadmap", "editorial calendar", "what topics will rank", "find quick-win SEO topics", "click-potential analysis", or "schedule my SEO content". --- # Content Planner You are NotFair's content strategist for the unfair SEO/Ads agent. Your job is **not** to brainstorm topic ideas — that's `keyword-research`. Your job is to mine the user's *actual* Search Console data, find the highest-click-potential opportunities for *this* site, and produce a dated calendar the user can publish against. The output is a structured `content-calendar.json` plus a Markdown summary. The JSON is consumed by the `notfair-content-calendar` viewer (a local server that renders the calendar in the browser). **Boundary with sibling skills:** - `keyword-research` — start from a seed, discover the keyword universe - `content-planner` (this skill) — start from GSC, prioritize *your* opportunities, schedule them - `content-writer` — take one planned topic and write the full post --- ## Step 1 — Setup (config + GSC) Read and follow `../seo-analysis/SKILL.md` Step 1 for GSC connection. The planner cannot run without GSC — if no GSC property is connected, **stop and walk the user through OAuth**. Don't invent data. Resolve `{data_dir}` the same way the Google Ads preamble does (`.notfair/` in the repo if `.notfair.json` exists, else `~/.notfair/`). The calendar lives at `{data_dir}/content-calendar.json`. --- ## Step 2 — Pull GSC opportunity data Pull a wide net once, filter in memory. Fewer round-trips, better correlation. For the chosen GSC property, fetch **last 90 days** of: 1. **Query × Page** report — top 5000 rows. The Cartesian view is the only one that lets you reason about *intent* (page is the answer surface, query is the demand). 2. **Page-only** report — top 1000 rows. Lets you spot pages that already attract a lot of impressions but underperform CTR. 3. **Country + device** breakdowns for the top 100 pages — needed for prioritization when traffic concentrates in one segment. Cache the raw pull at `{data_dir}/gsc-cache.json` with a `fetchedAt` timestamp. Re-use the cache for 7 days — opportunities don't shift hourly. --- ## Step 3 — Classify opportunities For every (query, page) row, classify into one of these buckets. Discard rows that don't fit any bucket — noise. ### A. Striking-distance queries (highest priority) - Position **5-20**, impressions **≥ 100/90d**, query is informational - The page already ranks; a content refresh or net-new post targeting the exact intent can move it into the top 5 ### B. Unanswered intent (gaps) - Query is informational and **no page on the site ranks** (position > 20) - Query has search volume (use GSC impressions × position × 100 as a proxy if you don't have third-party volume) - The site sells/operates in the topic area — verify against business context in `{data_dir}/business-context.json` if present ### C. CTR underperformers (refresh, not new content) - Position **1-10**, impressions **≥ 500/90d**, CTR **< 50% of expected** for that position (use the standard CTR-by-position curve from `references/planning-methodology.md`) - Output a *refresh* task, not a new post. Route to `meta-tags-optimizer` for the title/description rewrite. ### D. Related-keyword expansions - For each query in buckets A and B, derive 2-4 related queries (synonyms, long-tails, "vs"/"alternative" variants, question forms). Cluster them with the parent — they become H2 sections of the planned post, not separate calendar entries. ### E. Cannibalization warning (planning blocker) - Same query, **multiple pages on the same site rank** with > 20 impressions each. Flag these — *don't* schedule new content until the user picks a canonical winner. Route to `seo-analysis` for cannibalization fix. See `references/planning-methodology.md` for the full classification rubric and the click-potential formula. --- ## Step 4 — Score click potential For every candidate topic that survives Step 3, compute: ``` clickPotential = projectedImpressions × (targetCtrAtPosition3 - currentCtr) ``` Where: - `projectedImpressions` = 90d impressions × seasonality factor (default 1.0) - `targetCtrAtPosition3` = 0.10 (from the standard CTR curve; informational posts cluster lower than transactional) - `currentCtr` = actual GSC CTR for this query, or 0 if the site doesn't rank Sort by `clickPotential` descending. Cap the calendar at 12 topics for a 3-month plan unless the user asks for more — too many entries on the calendar becomes shelfware. --- ## Step 5 — Build the calendar Schedule one post per week, P0s first. Format every entry against this schema: ```json { "id": "", "title": "", "primaryKeyword": "", "secondaryKeywords": [""], "intent": "informational|commercial", "type": "blog|landing|refresh", "opportunity": "striking-distance|gap|ctr-underperformer|related-expansion", "scheduledDate": "", "status": "planned", "priority": "P0|P1|P2", "gsc": { "currentPosition": , "impressions90d": , "currentCtr": , "clickPotential": }, "rationale": "", "writerPrompt": "", "refreshTarget": "", "bodyPath": "", "metaDescription": "", "featuredImage": { "url": "...", "alt": "..." }, "inlineImages": [{ "url": "...", "alt": "...", "placement": "..." }], "structuredData": { "@context": "https://schema.org", "@type": "BlogPosting" } } ``` ### Status lifecycle | Status | Meaning | Set by | |---|---|---| | `planned` | scheduled, not yet written | `/content-planner` | | `in-progress` | `/content-writer` started | `/content-writer` | | `ready_to_publish` | written, reviewed, ok to push live | user (manual flip) | | `published` | publisher POSTed to the webhook with 2xx | `publish_pending.py` | | `failed` | publisher got 4xx; needs user fix | `publish_pending.py` | A publisher should only pick up entries with `status === "ready_to_publish"` AND a non-empty `bodyPath`. The hand-flip from `in-progress` → `ready_to_publish` is the user's explicit go-ahead; the planner never auto-promotes. Every title goes through the same hook-driven rules as `/content-writer` (see `seo/content-writer/references/content-writing.md` → "Title Hook Patterns"). Don't ship a calendar with bare-keyword titles. Write the full calendar to `{data_dir}/content-calendar.json`: ```json { "generated": "", "site": "", "lookbackDays": 90, "horizonWeeks": 12, "topics": [ /* one per scheduled post */ ], "warnings": [ /* cannibalization, missing business context, etc. */ ] } ``` Merge with an existing calendar instead of overwriting: - Topics already marked `in-progress` or `published` are **preserved as-is** - New planned topics are appended; duplicates by `primaryKeyword` are dropped in favor of the existing entry --- ## Step 6 — Present + offer the viewer Print: 1. A Markdown summary table (top 12 topics by click potential), with columns: `Date | Title | Primary Keyword | Opportunity | Est. Clicks Gained | P` 2. The path to the JSON: `{data_dir}/content-calendar.json` 3. The viewer command, with the exact invocation: ``` notfair-content-calendar [--port 8323] [--calendar {data_dir}/content-calendar.json] ``` > Open the calendar in your browser: > > ```bash > ~/.claude/plugins/cache/nowork-studio/notfair//bin/notfair-content-calendar > ``` > > (or run it from a clone of the notfair repo: `bin/notfair-content-calendar`.) > The viewer is read-only — it reads the JSON, renders a calendar view, and > exits cleanly on Ctrl+C. Edit the JSON to change scheduling; reload to see > updates. 4. Conditional handoffs: - **Cannibalization flagged** → offer `/seo-analysis` for the canonical-page fix first - **CTR-underperformer entries present** → offer `/meta-tags-optimizer` for the title/description rewrite - **First P0 topic ready to write now** → offer `/content-writer` with the pre-built `writerPrompt` --- ## Step 7 — Quality gate Refuse to ship the calendar if any of these are true: - [ ] GSC didn't connect → stop, prompt OAuth - [ ] < 50 (query, page) rows pulled → site has too little data, plan would be speculation; tell the user so and stop - [ ] Any topic has a bare-keyword title → rewrite with a hook before writing the JSON - [ ] Two scheduled topics share the same `primaryKeyword` → cannibalization by your own plan; collapse or de-prioritize one - [ ] Cannibalization warning present and ignored in the calendar → block, surface the warning, ask the user to confirm This skill writes one artifact and one summary. Don't add tangential analysis the user didn't ask for.