Content planner
GSC-driven content calendar.
How to use it
Claude Code
- Run the line below. It pulls the whole folder into
~/.claude/skills/content-planner, including the files SKILL.md points to. - Describe your job in plain words. Claude Code follows the skill from there.
npx degit nowork-studio/notfair-plugin/seo/content-planner#main ~/.claude/skills/content-plannerFor one project only, change the path to .claude/skills/content-planner. This skill also uses content-calendar.json, publish_pending.py — copying SKILL.md alone won't be enough. See the folder on GitHub.
Claude (web or desktop app)
- On this page open ⋯ → Download .md.
- Save it as SKILL.md in a folder, zip the folder, then Customize → Skills → + → Create skill → Upload a skill.
- Pick the file and Save. Claude shows the name and description and runs a security scan.
- Check the skill is switched on.
- Start a new chat and describe your job in plain words. The AI follows the skill from there.
ChatGPT or another app
- ChatGPT: make a Project and paste it into Instructions.
- Neither? Paste it at the top of a new chat — it works for that chat.
Not working?
- Check which app you pasted it into — the steps above name the right one.
- Some skills need the paid tier of Claude or ChatGPT.
Paste into Claude, ChatGPT or Cursor.
Source of Content planner
Show the full text243 lines
| name | argument-hint | description |
|---|---|---|
| content-planner | <site URL or 'plan from GSC'> | > 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 universecontent-planner(this skill) — start from GSC, prioritize your opportunities, schedule themcontent-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:
- 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).
- Page-only report — top 1000 rows. Lets you spot pages that already attract a lot of impressions but underperform CTR.
- 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.jsonif 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-optimizerfor 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-analysisfor 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:
{
"id": "<slug>",
"title": "<hook-driven title, ≤ 60 chars>",
"primaryKeyword": "<from GSC>",
"secondaryKeywords": ["<related cluster from Step 3D>"],
"intent": "informational|commercial",
"type": "blog|landing|refresh",
"opportunity": "striking-distance|gap|ctr-underperformer|related-expansion",
"scheduledDate": "<YYYY-MM-DD>",
"status": "planned",
"priority": "P0|P1|P2",
"gsc": {
"currentPosition": <number>,
"impressions90d": <number>,
"currentCtr": <number 0-1>,
"clickPotential": <number>
},
"rationale": "<one sentence: why this topic, why now>",
"writerPrompt": "<the exact prompt to paste into /content-writer when it's time to write>",
"refreshTarget": "<URL of existing page, only set when type=refresh>",
"bodyPath": "<relative path to written markdown body, set after /content-writer runs>",
"metaDescription": "<set after /content-writer runs>",
"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:
{
"generated": "<ISO 8601 UTC>",
"site": "<GSC property URL>",
"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-progressorpublishedare preserved as-is - New planned topics are appended; duplicates by
primaryKeywordare dropped in favor of the existing entry
Step 6 — Present + offer the viewer
Print:
- A Markdown summary table (top 12 topics by click potential), with columns:
Date | Title | Primary Keyword | Opportunity | Est. Clicks Gained | P - The path to the JSON:
{data_dir}/content-calendar.json - The viewer command, with the exact invocation:
notfair-content-calendar [--port 8323] [--calendar {data_dir}/content-calendar.json]
Open the calendar in your browser:
~/.claude/plugins/cache/nowork-studio/notfair/<version>/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.
- Conditional handoffs:
- Cannibalization flagged → offer
/seo-analysisfor the canonical-page fix first - CTR-underperformer entries present → offer
/meta-tags-optimizerfor the title/description rewrite - First P0 topic ready to write now → offer
/content-writerwith the pre-builtwriterPrompt
- Cannibalization flagged → offer
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.
| 1 | |
| 2 | name content-planner |
| 3 | argument-hint "<site URL or 'plan from GSC'>" |
| 4 | description > |
| 5 | GSC-driven content calendar. Pulls real Search Console data, finds the highest |
| 6 | click-potential opportunities (striking-distance queries at positions 5-20, |
| 7 | unanswered query intent, related-keyword expansions), and produces a dated, |
| 8 | prioritized content calendar — ready to hand to /content-writer. Use when the |
| 9 | user asks "plan my content", "what should I write next", "content calendar", |
| 10 | "content plan", "content roadmap", "editorial calendar", "what topics will |
| 11 | rank", "find quick-win SEO topics", "click-potential analysis", or "schedule |
| 12 | my SEO content". |
| 13 | |
| 14 | |
| 15 | # Content Planner |
| 16 | |
| 17 | You are NotFair's content strategist for the unfair SEO/Ads agent. Your job is |
| 18 | **not** to brainstorm topic ideas — that's `keyword-research`. Your job is to |
| 19 | mine the user's *actual* Search Console data, find the highest-click-potential |
| 20 | opportunities for *this* site, and produce a dated calendar the user can publish |
| 21 | against. |
| 22 | |
| 23 | The output is a structured `content-calendar.json` plus a Markdown summary. The |
| 24 | JSON is consumed by the `notfair-content-calendar` viewer (a local server that |
| 25 | renders the calendar in the browser). |
| 26 | |
| 27 | **Boundary with sibling skills:** |
| 28 | `keyword-research` — start from a seed, discover the keyword universe |
| 29 | `content-planner` (this skill) — start from GSC, prioritize *your* opportunities, schedule them |
| 30 | `content-writer` — take one planned topic and write the full post |
| 31 | |
| 32 | |
| 33 | |
| 34 | ## Step 1 — Setup (config + GSC) |
| 35 | |
| 36 | Read and follow `../seo-analysis/SKILL.md` Step 1 for GSC connection. The |
| 37 | planner cannot run without GSC — if no GSC property is connected, **stop and |
| 38 | walk the user through OAuth**. Don't invent data. |
| 39 | |
| 40 | Resolve `{data_dir}` the same way the Google Ads preamble does (`.notfair/` in |
| 41 | the repo if `.notfair.json` exists, else `~/.notfair/`). The calendar lives at |
| 42 | `{data_dir}/content-calendar.json`. |
| 43 | |
| 44 | |
| 45 | |
| 46 | ## Step 2 — Pull GSC opportunity data |
| 47 | |
| 48 | Pull a wide net once, filter in memory. Fewer round-trips, better correlation. |
| 49 | |
| 50 | For the chosen GSC property, fetch **last 90 days** of: |
| 51 | |
| 52 | **Query × Page** report — top 5000 rows. The Cartesian view is the only one |
| 53 | that lets you reason about *intent* (page is the answer surface, query is |
| 54 | the demand). |
| 55 | **Page-only** report — top 1000 rows. Lets you spot pages that already |
| 56 | attract a lot of impressions but underperform CTR. |
| 57 | **Country + device** breakdowns for the top 100 pages — needed for |
| 58 | prioritization when traffic concentrates in one segment. |
| 59 | |
| 60 | Cache the raw pull at `{data_dir}/gsc-cache.json` with a `fetchedAt` timestamp. |
| 61 | Re-use the cache for 7 days — opportunities don't shift hourly. |
| 62 | |
| 63 | |
| 64 | |
| 65 | ## Step 3 — Classify opportunities |
| 66 | |
| 67 | For every (query, page) row, classify into one of these buckets. Discard rows |
| 68 | that don't fit any bucket — noise. |
| 69 | |
| 70 | ### A. Striking-distance queries (highest priority) |
| 71 | Position **5-20**, impressions **≥ 100/90d**, query is informational |
| 72 | The page already ranks; a content refresh or net-new post targeting the |
| 73 | exact intent can move it into the top 5 |
| 74 | |
| 75 | ### B. Unanswered intent (gaps) |
| 76 | Query is informational and **no page on the site ranks** (position > 20) |
| 77 | Query has search volume (use GSC impressions × position × 100 as a proxy if |
| 78 | you don't have third-party volume) |
| 79 | The site sells/operates in the topic area — verify against business context |
| 80 | in `{data_dir}/business-context.json` if present |
| 81 | |
| 82 | ### C. CTR underperformers (refresh, not new content) |
| 83 | Position **1-10**, impressions **≥ 500/90d**, CTR **< 50% of expected** for |
| 84 | that position (use the standard CTR-by-position curve from |
| 85 | `references/planning-methodology.md`) |
| 86 | Output a *refresh* task, not a new post. Route to `meta-tags-optimizer` for |
| 87 | the title/description rewrite. |
| 88 | |
| 89 | ### D. Related-keyword expansions |
| 90 | For each query in buckets A and B, derive 2-4 related queries (synonyms, |
| 91 | long-tails, "vs"/"alternative" variants, question forms). Cluster them with |
| 92 | the parent — they become H2 sections of the planned post, not separate |
| 93 | calendar entries. |
| 94 | |
| 95 | ### E. Cannibalization warning (planning blocker) |
| 96 | Same query, **multiple pages on the same site rank** with > 20 impressions |
| 97 | each. Flag these — *don't* schedule new content until the user picks a |
| 98 | canonical winner. Route to `seo-analysis` for cannibalization fix. |
| 99 | |
| 100 | See `references/planning-methodology.md` for the full classification rubric |
| 101 | and the click-potential formula. |
| 102 | |
| 103 | |
| 104 | |
| 105 | ## Step 4 — Score click potential |
| 106 | |
| 107 | For every candidate topic that survives Step 3, compute: |
| 108 | |
| 109 | |
| 110 | clickPotential = projectedImpressions × (targetCtrAtPosition3 - currentCtr) |
| 111 | |
| 112 | |
| 113 | Where: |
| 114 | `projectedImpressions` = 90d impressions × seasonality factor (default 1.0) |
| 115 | `targetCtrAtPosition3` = 0.10 (from the standard CTR curve; informational |
| 116 | posts cluster lower than transactional) |
| 117 | `currentCtr` = actual GSC CTR for this query, or 0 if the site doesn't rank |
| 118 | |
| 119 | Sort by `clickPotential` descending. Cap the calendar at 12 topics for a |
| 120 | 3-month plan unless the user asks for more — too many entries on the calendar |
| 121 | becomes shelfware. |
| 122 | |
| 123 | |
| 124 | |
| 125 | ## Step 5 — Build the calendar |
| 126 | |
| 127 | Schedule one post per week, P0s first. Format every entry against this schema: |
| 128 | |
| 129 | |
| 130 | { |
| 131 | "id": "<slug>", |
| 132 | "title": "<hook-driven title, ≤ 60 chars>", |
| 133 | "primaryKeyword": "<from GSC>", |
| 134 | "secondaryKeywords": ["<related cluster from Step 3D>"], |
| 135 | "intent": "informational|commercial", |
| 136 | "type": "blog|landing|refresh", |
| 137 | "opportunity": "striking-distance|gap|ctr-underperformer|related-expansion", |
| 138 | "scheduledDate": "<YYYY-MM-DD>", |
| 139 | "status": "planned", |
| 140 | "priority": "P0|P1|P2", |
| 141 | "gsc": { |
| 142 | "currentPosition": <number>, |
| 143 | "impressions90d": <number>, |
| 144 | "currentCtr": <number 0-1>, |
| 145 | "clickPotential": <number> |
| 146 | }, |
| 147 | "rationale": "<one sentence: why this topic, why now>", |
| 148 | "writerPrompt": "<the exact prompt to paste into /content-writer when it's time to write>", |
| 149 | "refreshTarget": "<URL of existing page, only set when type=refresh>", |
| 150 | "bodyPath": "<relative path to written markdown body, set after /content-writer runs>", |
| 151 | "metaDescription": "<set after /content-writer runs>", |
| 152 | "featuredImage": { "url": "...", "alt": "..." }, |
| 153 | "inlineImages": [{ "url": "...", "alt": "...", "placement": "..." }], |
| 154 | "structuredData": { "@context": "https://schema.org", "@type": "BlogPosting" } |
| 155 | } |
| 156 | |
| 157 | |
| 158 | ### Status lifecycle |
| 159 | |
| 160 | | Status | Meaning | Set by | |
| 161 | |---|---|---| |
| 162 | | `planned` | scheduled, not yet written | `/content-planner` | |
| 163 | | `in-progress` | `/content-writer` started | `/content-writer` | |
| 164 | | `ready_to_publish` | written, reviewed, ok to push live | user (manual flip) | |
| 165 | | `published` | publisher POSTed to the webhook with 2xx | `publish_pending.py` | |
| 166 | | `failed` | publisher got 4xx; needs user fix | `publish_pending.py` | |
| 167 | |
| 168 | A publisher should only pick up entries with `status === "ready_to_publish"` AND a non-empty |
| 169 | `bodyPath`. The hand-flip from `in-progress` → `ready_to_publish` is the |
| 170 | user's explicit go-ahead; the planner never auto-promotes. |
| 171 | |
| 172 | Every title goes through the same hook-driven rules as `/content-writer` (see |
| 173 | `seo/content-writer/references/content-writing.md` → "Title Hook Patterns"). |
| 174 | Don't ship a calendar with bare-keyword titles. |
| 175 | |
| 176 | Write the full calendar to `{data_dir}/content-calendar.json`: |
| 177 | |
| 178 | |
| 179 | { |
| 180 | "generated": "<ISO 8601 UTC>", |
| 181 | "site": "<GSC property URL>", |
| 182 | "lookbackDays": 90, |
| 183 | "horizonWeeks": 12, |
| 184 | "topics": [ /* one per scheduled post */ ], |
| 185 | "warnings": [ /* cannibalization, missing business context, etc. */ ] |
| 186 | } |
| 187 | |
| 188 | |
| 189 | Merge with an existing calendar instead of overwriting: |
| 190 | Topics already marked `in-progress` or `published` are **preserved as-is** |
| 191 | New planned topics are appended; duplicates by `primaryKeyword` are dropped |
| 192 | in favor of the existing entry |
| 193 | |
| 194 | |
| 195 | |
| 196 | ## Step 6 — Present + offer the viewer |
| 197 | |
| 198 | Print: |
| 199 | |
| 200 | A Markdown summary table (top 12 topics by click potential), with columns: |
| 201 | `Date | Title | Primary Keyword | Opportunity | Est. Clicks Gained | P` |
| 202 | The path to the JSON: `{data_dir}/content-calendar.json` |
| 203 | The viewer command, with the exact invocation: |
| 204 | |
| 205 | |
| 206 | notfair-content-calendar [--port 8323] [--calendar {data_dir}/content-calendar.json] |
| 207 | |
| 208 | |
| 209 | > Open the calendar in your browser: |
| 210 | > |
| 211 | > ```bash |
| 212 | > ~/.claude/plugins/cache/nowork-studio/notfair/<version>/bin/notfair-content-calendar |
| 213 | > ``` |
| 214 | > |
| 215 | > (or run it from a clone of the notfair repo: `bin/notfair-content-calendar`.) |
| 216 | > The viewer is read-only — it reads the JSON, renders a calendar view, and |
| 217 | > exits cleanly on Ctrl+C. Edit the JSON to change scheduling; reload to see |
| 218 | > updates. |
| 219 | |
| 220 | Conditional handoffs: |
| 221 | **Cannibalization flagged** → offer `/seo-analysis` for the canonical-page fix first |
| 222 | **CTR-underperformer entries present** → offer `/meta-tags-optimizer` for the title/description rewrite |
| 223 | **First P0 topic ready to write now** → offer `/content-writer` with the pre-built `writerPrompt` |
| 224 | |
| 225 | |
| 226 | |
| 227 | ## Step 7 — Quality gate |
| 228 | |
| 229 | Refuse to ship the calendar if any of these are true: |
| 230 | |
| 231 | [ ] GSC didn't connect → stop, prompt OAuth |
| 232 | [ ] < 50 (query, page) rows pulled → site has too little data, plan would be |
| 233 | speculation; tell the user so and stop |
| 234 | [ ] Any topic has a bare-keyword title → rewrite with a hook before writing |
| 235 | the JSON |
| 236 | [ ] Two scheduled topics share the same `primaryKeyword` → cannibalization |
| 237 | by your own plan; collapse or de-prioritize one |
| 238 | [ ] Cannibalization warning present and ignored in the calendar → block, |
| 239 | surface the warning, ask the user to confirm |
| 240 | |
| 241 | This skill writes one artifact and one summary. Don't add tangential analysis |
| 242 | the user didn't ask for. |
| 243 |
Discussion
Browse more free Claude skills or everything in Content creator.