Keyword Research skill for Claude Code

A ranked page plan instead of a 5,000-row export. Claude starts from what the business sells, mines Search Console for striking-distance and cannibalized queries, then expands seeds from tools, autocomplete, and communities. A bundled script clusters keywords by shared top-10 URLs so each page targets one cluster, and clusters are scored with business value weighted double, so high-intent terms outrank vanity volume.

  • namekeyword-research
  • size163 lines · ≈1,950 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Builds a prioritized keyword map from seed topics, covering discovery from Search Console, keyword tools, autocomplete, and communities, intent classification, clustering by SERP overlap so one page targets one cluster, and scoring by business value, difficulty, and realistic traffic. Use when choosing what content to create, building a topic cluster or content roadmap, sizing an SEO opportunity, mapping keywords to existing pages, or when given a CSV export from Ahrefs, Semrush, or Google Keyword Planner to analyze.

Try asking

  • Here's our Ahrefs export for 'invoicing software'. Cluster it and tell me which pages to build first
  • Find keyword opportunities for our Postgres hosting product using our Search Console data
  • Build a topic cluster plan around remote team onboarding for our HR tool

Use it when

  • Deciding what content or landing pages to create next
  • Analyzing a keyword export from Ahrefs, Semrush, or Keyword Planner
  • Mapping keywords to existing pages and finding cannibalization
  • Sizing the organic search opportunity for a product or market

Skip it when

How to check the result

Every target cluster has a confirmed intent from a real SERP, maps to exactly one URL, and spot-checked clusters share ranking URLs rather than just similar words.

The common mistake it prevents

Sorting by search volume and creating a separate page per keyword variant, which yields cannibalizing thin pages aimed at people who will never buy.

~/.claude/skills/keyword-research/SKILL.md

--- name: keyword-research description: Builds a prioritized keyword map from seed topics, covering discovery from Search Console, keyword tools, autocomplete, and communities,… license: MIT ---

Done well means a short, ranked list of page-level opportunities: each row is a cluster of keywords one page can win, tagged with intent, mapped to a new or existing URL, and prioritized by what it is worth to this business — not a 5,000-row export sorted by volume.

Ground rules

  • Start from the business: what the product does, who buys it, what they search before buying. Volume without fit is noise.
  • Search Console data for the site beats any third-party estimate. Use it first when the site has history.
  • Third-party volumes and difficulty scores are modeled estimates that disagree across tools by 2–5x. Compare keywords within one tool; never mix scores from different tools.
  • Cluster by SERP overlap, not by string similarity. “crm for startups” and “best startup crm” are one page if Google shows the same results.
  • Intent comes from the live SERP. A keyword whose results are product pages cannot be won with a blog post.
  • AI Overviews absorb clicks on simple informational queries. Discount expected CTR for definitional queries that trigger them; weight commercial and complex queries higher.

Workflow

- [ ] 1. Define the business frame (offer, audience, conversion)
- [ ] 2. Mine existing demand (Search Console)
- [ ] 3. Expand seeds (tools, autocomplete, PAA, communities, competitors)
- [ ] 4. Clean and dedupe
- [ ] 5. Classify intent and funnel stage
- [ ] 6. Cluster by SERP overlap
- [ ] 7. Score and prioritize
- [ ] 8. Map clusters to URLs and deliver

1. Business frame

Write down: product and category names, 3–5 core jobs-to-be-done, buyer personas, competitors, geography and language, conversion actions with rough value (trial, demo, purchase). Ask for these if missing; they drive every scoring decision later.

2. Mine existing demand

From Search Console (Performance → Search results, last 16 months, export queries and pages):

  • Striking distance: queries at average position 5–20 with meaningful impressions — cheapest wins via on-page improvements.
  • High impressions, low CTR: title/snippet or intent mismatch.
  • Cannibalization: one query spread across several URLs with alternating positions.
  • Unexpected queries: demand you did not plan for, often the best new topics.

GSC anonymizes rare queries and caps the UI export at 1,000 rows; use the Search Analytics API or the BigQuery bulk export for larger sites.

3. Expand seeds

SourceWhat it gives
Keyword tool (Ahrefs, Semrush, Moz, Keyword Planner)Volume, difficulty, related terms, competitor rankings
Google autocomplete and “People also ask”Real phrasing and follow-up questions
Competitor ranking keywords / content gapProven demand you lack
Reddit, forums, Stack Overflow, Discord, reviewsPain language, comparisons, objections
Sales calls, support tickets, onsite search logsBottom-funnel terms competitors miss
Modifiersbest, vs, alternative, pricing, template, example, how to, for {audience}, near me

Keyword Planner shows ranges unless the account has active spend; treat as directional.

4. Clean

  • Lowercase, trim, dedupe; merge plural/singular and word-order variants only after SERP checks (sometimes they differ).
  • Remove irrelevant meanings (brand names that collide, other industries), navigational queries for other brands (unless you sell an alternative), and queries you cannot serve.
  • Flag competitor-brand terms separately; they suit “alternative”/“vs” pages only.

5. Classify intent

IntentTypical modifiersPage typeFunnel
Informationalhow to, what is, guide, examplesGuide, tutorial, glossaryTop
Commercial investigationbest, top, vs, alternatives, review, pricingComparison, listicle, alternatives pageMiddle
Transactionalbuy, price, signup, free trial, template downloadProduct, pricing, landing pageBottom
Navigationalbrand, login, docsExisting brand pages—
Localnear me, in {city}Location pages, Business ProfileBottom

Confirm intent for every keyword you plan to target by opening the SERP (or using SERP data from your tool). Mixed SERPs mean Google is unsure; target the dominant format.

6. Cluster by SERP overlap

Export the top-10 ranking URLs per keyword from your SEO tool (or a SERP API) in long format (keyword,url,volume,kd), then run:

python3 scripts/cluster_keywords.py serp.csv --min-shared 3 --out clusters.csv

The script picks hub keywords in volume order (a hub shares fewer than N top-10 URLs with every earlier hub), attaches each remaining keyword to the hub it overlaps most, and sums volume per cluster; the hub is the cluster’s primary keyword. Tune --min-shared: 3–4 for broad clusters (pillar pages), 5+ for tight ones. Review clusters by hand: split any that mix intents, merge singletons that obviously belong together.

Without SERP data, group by intent + core noun phrase, but mark those clusters “unverified” and spot-check 5 SERPs before committing to pages.

7. Score and prioritize

Score each cluster 1–5 on four factors, then rank:

Factor15
Business valueTangential audience, no path to productSearcher is in-market and the product solves it directly
DemandCluster volume in the bottom quartile of your listTop quartile (or high-value low-volume B2B term)
WinnabilityTop 10 dominated by much stronger domains with deep contentWeak/old/thin results, forums ranking, or you already rank 5–20
Effort (inverted)Needs original research, tools, or many pagesOptimize an existing page

priority = value × 2 + demand + winnability + effort — business value is weighted double so low-volume, high-intent terms beat vanity traffic. Use difficulty scores as one input to winnability, alongside a manual look at who ranks.

Estimate traffic conservatively: cluster volume × expected CTR for the target position (roughly 20–30% for #1, 10–15% for #3, ~5% for #5–#7, lower when AI Overviews, ads, or shopping units sit above the fold).

8. Map and deliver

  • Each cluster → one URL. Prefer an existing URL that already ranks for any keyword in the cluster; otherwise mark “new”.
  • Pillar/cluster structure: a broad pillar page linking to cluster pages and back.
  • Hand top clusters to seo-content-briefs; templated patterns to programmatic-seo.

Gotchas

  • Zero-volume keywords in tools often have real demand (especially B2B and new categories). Do not discard bottom-funnel terms seen in sales calls because a tool says 0.
  • Seasonal terms: check 12-month trends (Google Trends or the tool’s history) before judging volume from one month.
  • Difficulty scores mostly measure backlinks to ranking pages; they miss topical authority, freshness, and intent fit.
  • Targeting each keyword variant with its own page creates cannibalization and thin pages.
  • Country matters: volumes, SERPs, and spelling differ between US/UK/AU; research per market.

Output template

## Keyword map — <site>, <market>, <date>
Business frame: <offer, audience, conversion>. Sources: <GSC 16 mo, Ahrefs US, Reddit>.

| Rank | Cluster (primary) | Keywords | Volume | Intent | Value | Demand | Win | Effort | Priority | URL (existing/new) | Next step |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | crm for startups | 14 | 4,900 | Commercial | 5 | 4 | 3 | 4 | 21 | /crm-for-startups (new) | Brief |
| 2 | sales pipeline template | 6 | 2,100 | Transactional | 4 | 3 | 4 | 5 | 20 | /templates/pipeline (rank 11) | Optimize |

Quick wins (striking distance): <list with current position>
Cannibalization found: <query → URLs>
Excluded and why: <brand collisions, off-audience>

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

scripts/cluster_keywords.py 109 lines · 4.3 KB
#!/usr/bin/env python3
"""Cluster keywords by SERP overlap (stdlib only).

Keywords whose top-10 results share at least N URLs are served by one page.

Input CSV (header row required), long format, one row per keyword+ranking URL:
    keyword,url,volume,kd
    crm for startups,https://a.com/crm,1300,32
    crm for startups,https://b.com/best-crm,1300,32
    ...
`volume` and `kd` are optional; when present they only need to appear once per keyword.

Usage:
    python3 cluster_keywords.py serp.csv [--min-shared 3] [--out clusters.csv]

Prints clusters sorted by total volume and optionally writes a CSV of
cluster_id,primary_keyword,keyword,volume,kd,shared_with_primary.
Exit 1 if the input is missing required columns or has no rows.
"""
from __future__ import annotations

import argparse
import csv
import sys
from collections import defaultdict
from urllib.parse import urlsplit


def norm(url: str) -> str:
    p = urlsplit(url.strip().lower())
    host = p.netloc.removeprefix("www.")
    return f"{host}{p.path.rstrip('/')}"


def to_num(v: str | None) -> float:
    try:
        return float((v or "").replace(",", "").strip() or 0)
    except ValueError:
        return 0.0


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("csv")
    ap.add_argument("--min-shared", type=int, default=3, help="shared top-10 URLs to group (default 3)")
    ap.add_argument("--out", help="write clusters to this CSV")
    a = ap.parse_args()

    serps: dict[str, set[str]] = defaultdict(set)
    vol: dict[str, float] = {}
    kd: dict[str, float] = {}
    with open(a.csv, newline="", encoding="utf-8-sig") as f:
        reader = csv.DictReader(f)
        cols = {c.lower().strip() for c in (reader.fieldnames or [])}
        if not {"keyword", "url"} <= cols:
            print("ERROR: CSV needs at least 'keyword' and 'url' columns")
            return 1
        for row in reader:
            row = {k.lower().strip(): v for k, v in row.items() if k}
            k = (row.get("keyword") or "").strip().lower()
            if not k:
                continue
            if row.get("url"):
                serps[k].add(norm(row["url"]))
            vol[k] = max(vol.get(k, 0), to_num(row.get("volume")))
            kd[k] = max(kd.get(k, 0), to_num(row.get("kd")))
    if not serps:
        print("ERROR: no keyword rows found")
        return 1

    order = sorted(serps, key=lambda k: (-vol.get(k, 0), k))
    # Pass 1: pick hubs in volume order; a keyword becomes a hub if it overlaps no existing hub.
    hubs: list[str] = []
    for k in order:
        if not any(len(serps[k] & serps[h]) >= a.min_shared for h in hubs):
            hubs.append(k)
    # Pass 2: attach every other keyword to the hub it shares the most URLs with.
    clusters_by_hub: dict[str, list[tuple[str, int]]] = {h: [(h, len(serps[h]))] for h in hubs}
    for k in order:
        if k in clusters_by_hub:
            continue
        best = max(hubs, key=lambda h: (len(serps[k] & serps[h]), vol.get(h, 0)))
        clusters_by_hub[best].append((k, len(serps[k] & serps[best])))
    clusters: list[list[tuple[str, int]]] = list(clusters_by_hub.values())

    clusters.sort(key=lambda m: -sum(vol.get(k, 0) for k, _ in m))
    rows = []
    for i, members in enumerate(clusters, 1):
        total = sum(vol.get(k, 0) for k, _ in members)
        primary = members[0][0]
        print(f"\n[{i}] {primary}  (keywords: {len(members)}, total volume: {total:,.0f}, primary KD: {kd.get(primary, 0):.0f})")
        for k, shared in members:
            tag = "primary" if k == primary else f"{shared} shared URLs"
            print(f"    {k:<50} vol {vol.get(k, 0):>8,.0f}  kd {kd.get(k, 0):>3.0f}  {tag}")
            rows.append([i, primary, k, int(vol.get(k, 0)), int(kd.get(k, 0)), "" if k == primary else shared])

    singletons = sum(1 for m in clusters if len(m) == 1)
    print(f"\n{len(clusters)} clusters from {len(serps)} keywords ({singletons} singletons) at min-shared={a.min_shared}")
    if a.out:
        with open(a.out, "w", newline="", encoding="utf-8") as f:
            w = csv.writer(f)
            w.writerow(["cluster_id", "primary_keyword", "keyword", "volume", "kd", "shared_with_primary"])
            w.writerows(rows)
        print(f"wrote {a.out}")
    return 0


if __name__ == "__main__":
    sys.exit(main())

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc